feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Telemetry beacon interop (Phase 16 §2.2). A thin wrapper over navigator.sendBeacon for fire-and-forget
|
||||
* play/share events, plus a page-unload handler that lets the player close an open play session as the
|
||||
* tab goes away. sendBeacon (not fetch) is the load-bearing choice: it survives page unload, where a
|
||||
* fetch would be cancelled — exactly the tab-close edge case the play metric must still record.
|
||||
*
|
||||
* Exposed on window.DeepDrftBeacon; imported once in App.razor alongside the audio engine.
|
||||
*/
|
||||
|
||||
// .NET interop type — a DotNetObjectReference the unload handler invokes back into.
|
||||
interface DotNetObjectReference {
|
||||
invokeMethodAsync(methodName: string, ...args: unknown[]): Promise<unknown>;
|
||||
invokeMethod(methodName: string, ...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
// Registered unload listeners. Holding the handler lets us detach on dispose so a torn-down player
|
||||
// circuit does not get called into.
|
||||
type UnloadEntry = { dotNetRef: DotNetObjectReference; methodName: string };
|
||||
const unloadHandlers = new Map<string, UnloadEntry>();
|
||||
|
||||
let unloadWired = false;
|
||||
|
||||
// Fire every registered unload handler synchronously. invokeMethod (sync) — not invokeMethodAsync — is
|
||||
// required here: in pagehide/visibilitychange→hidden the event loop will not pump a microtask before the
|
||||
// page is frozen, so an awaited call would never run. The .NET side does only synchronous beacon work.
|
||||
function fireUnloadHandlers(): void {
|
||||
for (const { dotNetRef, methodName } of unloadHandlers.values()) {
|
||||
try {
|
||||
dotNetRef.invokeMethod(methodName);
|
||||
} catch {
|
||||
// A torn-down circuit or a transient interop failure must never block unload.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireUnloadOnce(): void {
|
||||
if (unloadWired) return;
|
||||
unloadWired = true;
|
||||
|
||||
// pagehide is the canonical "page is going away" signal (covers tab close, navigation, and the
|
||||
// bfcache freeze). visibilitychange→hidden additionally covers the mobile case where the tab is
|
||||
// backgrounded and may be discarded without a pagehide. Both funnel to the same close path; the
|
||||
// .NET side is idempotent, so a double-fire closes the session at most once.
|
||||
window.addEventListener('pagehide', fireUnloadHandlers);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') fireUnloadHandlers();
|
||||
});
|
||||
}
|
||||
|
||||
const DeepDrftBeacon = {
|
||||
/**
|
||||
* Queue a fire-and-forget POST of a small JSON body. Returns false if the browser refused to queue
|
||||
* the beacon (e.g. over the per-origin byte budget) — callers ignore it; a dropped telemetry event
|
||||
* is acceptable by design.
|
||||
*/
|
||||
send: (url: string, json: string): boolean => {
|
||||
try {
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return navigator.sendBeacon(url, blob);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a .NET callback to run on page unload (and on visibility→hidden). Keyed so a given player
|
||||
* registers once and can replace/detach cleanly across its lifecycle.
|
||||
*/
|
||||
registerUnload: (key: string, dotNetRef: DotNetObjectReference, methodName: string): void => {
|
||||
wireUnloadOnce();
|
||||
unloadHandlers.set(key, { dotNetRef, methodName });
|
||||
},
|
||||
|
||||
/** Detach a previously-registered unload callback (player dispose). */
|
||||
unregisterUnload: (key: string): void => {
|
||||
unloadHandlers.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeepDrftBeacon: typeof DeepDrftBeacon;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeepDrftBeacon = DeepDrftBeacon;
|
||||
|
||||
export { DeepDrftBeacon };
|
||||
Reference in New Issue
Block a user