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
+13 -2
View File
@@ -88,8 +88,15 @@ public sealed class PlayTracker
/// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
/// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
/// first call emits.
///
/// <para><paramref name="viaUnload"/> selects the transport, not the classification (telemetry
/// transport-resilience). The default (false) is the normal close (organic end / track-switch / stop):
/// the page is alive, so the event goes over the first-party fetch arm. The unload handler passes true
/// so the rare tab-close mid-play uses <c>sendBeacon</c>, the only transport that survives the freeze.
/// The fetch arm is fire-and-forget here because the close paths are sync-shaped (a void JS callback,
/// or a teardown we must not block on a telemetry POST) — on a live page the task still completes.</para>
/// </summary>
public void Close()
public void Close(bool viaUnload = false)
{
if (!HasOpenSession)
{
@@ -112,7 +119,11 @@ public sealed class PlayTracker
if (!CrossesFloor(_highWater, duration))
return;
_sink.EmitPlay(key, Classify(fraction));
var bucket = Classify(fraction);
if (viaUnload)
_sink.EmitPlayOnUnload(key, bucket);
else
_ = _sink.EmitPlayAsync(key, bucket);
}
// The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).