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
@@ -5,12 +5,30 @@ namespace DeepDrftPublic.Client.Services;
/// <summary>
/// The emit seam for the <see cref="PlayTracker"/> (Phase 16 §2.1). The tracker owns the session
/// lifecycle, the engagement floor, and the bucket classification but knows nothing about transport —
/// it hands a finished classification to a sink. The production sink fires a <c>sendBeacon</c> POST to
/// <c>api/event/play</c>; tests substitute a fake sink to assert floor and bucket behaviour with no
/// JS interop. This keeps the tracker's logic testable behind one seam, as the spec calls for.
/// it hands a finished classification to a sink, choosing only which arm fits the close that triggered
/// it. Two arms exist because the close paths differ in whether the page survives the call (telemetry
/// transport-resilience):
/// <list type="bullet">
/// <item><see cref="EmitPlayAsync"/> — normal closes (organic end / track-switch / stop), where the page
/// stays alive, go over a first-party <c>HttpClient</c> POST to <c>api/event/play</c>. A first-party
/// fetch is not name-matched by tracking/fingerprinting heuristics the way a <c>sendBeacon</c> module is.</item>
/// <item><see cref="EmitPlayOnUnload"/> — the page-unload edge (pagehide / visibility→hidden), where an
/// awaited fetch would be cancelled, still goes over <c>navigator.sendBeacon</c>.</item>
/// </list>
/// Tests substitute a fake sink to assert floor and bucket behaviour with no transport.
/// </summary>
public interface IPlayEventSink
{
/// <summary>Emit one recorded play. Called at most once per session, only when the floor is crossed.</summary>
void EmitPlay(string trackEntryKey, PlayBucket bucket);
/// <summary>
/// Emit one recorded play over the first-party fetch transport (normal close: end / switch / stop).
/// Called at most once per session, only when the floor is crossed. Awaitable but safe to
/// fire-and-forget on a live page; never throws.
/// </summary>
Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket);
/// <summary>
/// Emit one recorded play over <c>sendBeacon</c> for the page-unload edge, where an awaited fetch
/// would be cancelled as the page freezes. Synchronous and fire-and-forget; never throws.
/// </summary>
void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket);
}