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
@@ -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);
}