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:
daniel-c-harvey
2026-06-19 12:59:00 -04:00
parent 1931574ad4
commit dbd90ee52a
35 changed files with 2460 additions and 2 deletions
+1
View File
@@ -25,6 +25,7 @@
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
<script type="module">
import('./js/audio/index.js');
import('./js/telemetry/beacon.js');
</script>
</body>
</html>
@@ -0,0 +1,70 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies the anonymous telemetry write endpoints (<c>POST api/event/play</c> / <c>api/event/share</c>)
/// to DeepDrftAPI so the WASM client never makes a cross-origin request (Phase 16 §2.2). Mirrors
/// <see cref="TrackProxyController"/>'s idiom — the named <c>"DeepDrft.API"</c> client forwards the
/// request upstream — but for a POST write: the small JSON body is buffered and relayed verbatim, and
/// the upstream status (202 on success, 4xx on a rejected payload, 429 on rate limit) passes back so the
/// beacon's fire-and-forget contract is preserved end to end. SSR never posts these — they originate
/// from the browser player/share surfaces only.
/// </summary>
// A sendBeacon POST cannot attach a Blazor antiforgery token, so the telemetry write routes opt out
// explicitly. They are anonymous, idempotent-enough fire-and-forget logging — there is no
// state-changing user action to protect with CSRF tokens, and the upstream rate-limits by IP.
[ApiController]
[Route("api/event")]
[IgnoreAntiforgeryToken]
public class EventProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<EventProxyController> _logger;
public EventProxyController(IHttpClientFactory httpClientFactory, ILogger<EventProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies a play event upstream. Body is opaque JSON — validated by DeepDrftAPI, not here.</summary>
[HttpPost("play")]
public Task<ActionResult> ForwardPlay(CancellationToken ct = default) => Forward("api/event/play", ct);
/// <summary>Proxies a share event upstream.</summary>
[HttpPost("share")]
public Task<ActionResult> ForwardShare(CancellationToken ct = default) => Forward("api/event/share", ct);
private async Task<ActionResult> Forward(string upstreamPath, CancellationToken ct)
{
// Buffer the small JSON body and relay it verbatim. Reading the raw body keeps the proxy
// transparent — it does not deserialize or re-shape the payload, just forwards it.
string body;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
{
body = await reader.ReadToEndAsync(ct);
}
using var content = new StringContent(body, Encoding.UTF8, "application/json");
HttpResponseMessage upstream;
try
{
upstream = await _upstream.PostAsync(upstreamPath, content, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI {Path} failed", upstreamPath);
return StatusCode(502, "Upstream unavailable");
}
// Relay the upstream status as-is. Telemetry is fire-and-forget; the beacon never reads the
// body, so there is nothing to relay beyond the code (202 / 400 / 429 / 5xx).
using (upstream)
{
return StatusCode((int)upstream.StatusCode);
}
}
}
@@ -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 };