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