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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user