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
@@ -3,11 +3,16 @@ using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Thin C# wrapper over the <c>window.DeepDrftBeacon</c> TS interop (Phase 16 §2.2). Wraps the
/// <c>navigator.sendBeacon</c> POST and the page-unload registration so the rest of the client never
/// touches <see cref="IJSRuntime"/> string identifiers directly. All calls are best-effort: a JS
/// failure (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must
/// never throw into the UI or the playback path.
/// Thin C# wrapper over the <c>window.DeepDrftLifecycle</c> TS interop. Wraps the <c>navigator.sendBeacon</c>
/// POST and the page-unload registration so the rest of the client never touches <see cref="IJSRuntime"/>
/// string identifiers directly. After the transport-resilience split this is the <b>unload-edge transport
/// only</b>: normal play closes and shares go over the first-party <see cref="IEventPoster"/> fetch, and
/// <c>sendBeacon</c> is retained solely for the page-unload path (pagehide / visibility→hidden) where an
/// awaited fetch would be cancelled. The module is named off the former <c>telemetry/beacon</c> path
/// (<c>DeepDrftLifecycle</c>, served from <c>js/session/lifecycle.js</c>) so even this retained fallback is
/// not caught by name-based tracking/fingerprinting blockers. All calls are best-effort: a JS failure
/// (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must never throw
/// into the UI or the playback path.
/// </summary>
public sealed class BeaconInterop
{
@@ -23,7 +28,7 @@ public sealed class BeaconInterop
{
try
{
await _js.InvokeAsync<bool>("DeepDrftBeacon.send", url, json);
await _js.InvokeAsync<bool>("DeepDrftLifecycle.send", url, json);
}
catch
{
@@ -37,7 +42,7 @@ public sealed class BeaconInterop
{
try
{
await _js.InvokeVoidAsync("DeepDrftBeacon.registerUnload", key, dotNetRef, methodName);
await _js.InvokeVoidAsync("DeepDrftLifecycle.registerUnload", key, dotNetRef, methodName);
}
catch
{
@@ -50,7 +55,7 @@ public sealed class BeaconInterop
{
try
{
await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
await _js.InvokeVoidAsync("DeepDrftLifecycle.unregisterUnload", key);
}
catch
{
@@ -7,28 +7,38 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2): serializes the play classification and fires
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
/// awaiting and its failure is irrelevant. The current <c>anonId</c> (wave 16.3) is read synchronously
/// from the warmed <see cref="IAnonIdProvider"/> cache and omitted when null (storage unavailable / not
/// yet warmed) — an anonId-less play still counts, it just doesn't contribute to the listener tally.
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2; telemetry transport-resilience). Serializes the
/// play classification once and dispatches it down the arm the close chose:
/// <list type="bullet">
/// <item><see cref="EmitPlayAsync"/> posts via the first-party <see cref="IEventPoster"/> — a same-origin
/// <c>HttpClient</c> fetch to <c>api/event/play</c>, used for the normal close paths (organic end /
/// track-switch / stop) that a privacy-hardened browser would block if they used a name-matched
/// <c>sendBeacon</c> module.</item>
/// <item><see cref="EmitPlayOnUnload"/> fires <see cref="BeaconInterop"/> (<c>sendBeacon</c>) for the
/// tab-unload edge, where an awaited fetch would be cancelled.</item>
/// </list>
/// Both arms send byte-identical payloads (same DTO, same anonId, same JSON options). The current
/// <c>anonId</c> (wave 16.3) is read synchronously from the warmed <see cref="IAnonIdProvider"/> cache and
/// omitted when null (storage unavailable / not yet warmed) — an anonId-less play still counts, it just
/// doesn't contribute to the listener tally.
/// </summary>
public sealed class BeaconPlayEventSink : IPlayEventSink
{
// Omit a null anonId from the wire payload (§2.2 — "omitted entirely" when absent) rather than
// sending "anonId":null. The API treats absent and null identically, so this is cosmetic minimalism;
// it does not change the integer enum encoding the 16.1 contract already relies on.
private static readonly JsonSerializerOptions BeaconJson =
private static readonly JsonSerializerOptions EventJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly IEventPoster _poster;
private readonly BeaconInterop _beacon;
private readonly IAnonIdProvider _anonId;
private readonly string _playUrl;
public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
public BeaconPlayEventSink(
IEventPoster poster, BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
{
_poster = poster;
_beacon = beacon;
_anonId = anonId;
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
@@ -36,17 +46,19 @@ public sealed class BeaconPlayEventSink : IPlayEventSink
_playUrl = $"{navigation.BaseUri}api/event/play";
}
public void EmitPlay(string trackEntryKey, PlayBucket bucket)
{
var json = JsonSerializer.Serialize(new PlayEventDto
public Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket)
=> _poster.PostAsync(_playUrl, Serialize(trackEntryKey, bucket));
public void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket)
// Fire-and-forget: the beacon survives unload; the C# task may not, and we do not act on the
// result either way.
=> _ = _beacon.SendAsync(_playUrl, Serialize(trackEntryKey, bucket));
private string Serialize(string trackEntryKey, PlayBucket bucket)
=> JsonSerializer.Serialize(new PlayEventDto
{
TrackEntryKey = trackEntryKey,
Bucket = bucket,
AnonId = _anonId.Current,
}, BeaconJson);
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
// act on the result either way.
_ = _beacon.SendAsync(_playUrl, json);
}
}, EventJson);
}
@@ -0,0 +1,46 @@
using System.Text;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IEventPoster"/>: a first-party, same-origin <see cref="System.Net.Http.HttpClient"/>
/// POST to the site's own <c>api/event/*</c> proxy (telemetry transport-resilience). A fetch to the host's
/// own origin is not third-party tracking and is not caught by the name-based heuristics (Firefox
/// Fingerprinting / Tracking Protection) that block a <c>telemetry/beacon</c>-named <c>sendBeacon</c>
/// module. Used for the awaitable play-close paths (organic end / track-switch / stop) and every share
/// event; only the rare tab-unload edge still goes through <see cref="BeaconInterop"/>, where an awaited
/// fetch would be cancelled as the page freezes.
///
/// <para>Best-effort and non-throwing by contract: a failed POST (offline, blocked, server error) is
/// swallowed so a dropped telemetry event never throws into the UI or the playback path — identical
/// posture to the beacon transport.</para>
/// </summary>
public sealed class HttpEventPoster : IEventPoster
{
// The default factory client carries no base address; the sink/tracker pass an absolute same-origin
// URL built from NavigationManager.BaseUri, so the POST targets the host proxy regardless of how the
// named clients are configured. The default client uses the browser fetch handler in WASM, which is
// exactly the first-party request the heuristic blockers permit.
private readonly IHttpClientFactory _httpClientFactory;
public HttpEventPoster(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task PostAsync(string url, string json)
{
try
{
using var client = _httpClientFactory.CreateClient();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
// Best-effort: the server records the event; we do not act on the status either way.
using var response = await client.PostAsync(url, content);
}
catch
{
// Swallow — a dropped telemetry event is acceptable; telemetry must never throw into the UI
// or the playback path. Mirrors the beacon's fire-and-forget contract.
}
}
}
@@ -0,0 +1,19 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// The first-party event transport seam (telemetry transport-resilience). Sends a serialized event body
/// to a same-origin <c>api/event/*</c> route over <see cref="System.Net.Http.HttpClient"/> — a fetch to
/// the site's own host proxy, which privacy / tracking heuristics do not block the way they name-match a
/// <c>sendBeacon</c> module. Abstracted so the play sink and the share tracker share one best-effort,
/// non-throwing POST, and so tests can capture the wire payload behind a fake with no HTTP — the same
/// one-seam pattern as <see cref="IPlayEventSink"/>.
/// </summary>
public interface IEventPoster
{
/// <summary>
/// Best-effort POST of <paramref name="json"/> (an <c>application/json</c> body) to
/// <paramref name="url"/>. Never throws: a failed POST is swallowed so telemetry cannot break the UI
/// or the playback path. Awaitable, but safe to fire-and-forget on a live page.
/// </summary>
Task PostAsync(string url, string json);
}
@@ -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);
}
+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).
+13 -10
View File
@@ -10,13 +10,16 @@ namespace DeepDrftPublic.Client.Services;
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
/// fires the event via <c>navigator.sendBeacon</c> to the proxied <c>api/event/share</c> route.
/// fires the event via the first-party <see cref="IEventPoster"/> POST to the proxied <c>api/event/share</c>
/// route. A share is always a user-interaction close with the page alive (never a tab-unload), so it uses
/// the fetch transport unconditionally — there is no <c>sendBeacon</c> arm here (telemetry
/// transport-resilience).
///
/// <para>
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
/// The beacon send is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from
/// the warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
/// The POST is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from the
/// warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
/// </para>
/// </summary>
public sealed class ShareTracker
@@ -27,17 +30,17 @@ public sealed class ShareTracker
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
// change the integer enum encoding the 16.1 contract relies on.
private static readonly JsonSerializerOptions BeaconJson =
private static readonly JsonSerializerOptions EventJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly BeaconInterop _beacon;
private readonly IEventPoster _poster;
private readonly IAnonIdProvider _anonId;
private readonly string _shareUrl;
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
public ShareTracker(IEventPoster poster, IAnonIdProvider anonId, NavigationManager navigation)
{
_beacon = beacon;
_poster = poster;
_anonId = anonId;
_shareUrl = $"{navigation.BaseUri}api/event/share";
}
@@ -71,10 +74,10 @@ public sealed class ShareTracker
TargetKey = targetKey,
Channel = channel,
AnonId = _anonId.Current,
}, BeaconJson);
}, EventJson);
// Fire-and-forget — a dropped share telemetry event is acceptable.
_ = _beacon.SendAsync(_shareUrl, json);
// Fire-and-forget first-party POST — a dropped share telemetry event is acceptable.
_ = _poster.PostAsync(_shareUrl, json);
return true;
}
}
@@ -121,7 +121,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
/// freezes. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
/// </summary>
[JSInvokable]
public void OnPageUnload() => _playTracker?.Close();
public void OnPageUnload() => _playTracker?.Close(viaUnload: true);
// Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
// lowers it — the tracker takes the max.