Merge p16-w3-anonid into dev (Phase 16 Wave 16.3: unique-listener anonId layer)

This commit is contained in:
daniel-c-harvey
2026-06-19 14:43:46 -04:00
16 changed files with 680 additions and 12 deletions
@@ -0,0 +1,41 @@
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IAnonIdProvider"/> over the <c>window.DeepDrftAnonId</c> TS interop. Reads the
/// first-party <c>localStorage</c> GUID once and caches it for the session, so the synchronous emit paths
/// read it with no JS hop. Scoped (per-session) like the other telemetry collaborators; the underlying
/// token itself outlives the session in <c>localStorage</c> — the cache just avoids repeated interop.
/// </summary>
public sealed class AnonIdProvider : IAnonIdProvider
{
private readonly IJSRuntime _js;
private bool _loaded;
public AnonIdProvider(IJSRuntime js)
{
_js = js;
}
public string? Current { get; private set; }
public async ValueTask EnsureLoadedAsync()
{
if (_loaded) return;
try
{
// The module returns null when localStorage is unavailable; we store that null and still
// mark loaded so we don't retry every emit. A genuine interop failure (module not yet
// imported, prerender) is caught below and leaves _loaded false so a later warm can succeed.
Current = await _js.InvokeAsync<string?>("DeepDrftAnonId.get");
_loaded = true;
}
catch
{
// Interop unavailable (prerender / module not loaded). Leave Current null and unloaded so a
// subsequent warm retries — telemetry simply omits the id until then.
}
}
}
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
@@ -10,16 +11,26 @@ namespace DeepDrftPublic.Client.Services;
/// 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. No <c>anonId</c> is sent in wave 16.1.
/// 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.
/// </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 =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly BeaconInterop _beacon;
private readonly IAnonIdProvider _anonId;
private readonly string _playUrl;
public BeaconPlayEventSink(BeaconInterop beacon, NavigationManager navigation)
public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
{
_beacon = beacon;
_anonId = anonId;
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
// trailing slash; the route does not lead with one.
_playUrl = $"{navigation.BaseUri}api/event/play";
@@ -31,7 +42,8 @@ public sealed class BeaconPlayEventSink : IPlayEventSink
{
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.
@@ -0,0 +1,32 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Supplies the client-minted anonymous listener id (Phase 16 §3, wave 16.3, D5 Option A) to the
/// fire-and-forget telemetry sinks. The id is a random first-party <c>localStorage</c> GUID — opaque, no
/// PII, no fingerprinting, clearable. Split into an async warm (<see cref="EnsureLoadedAsync"/>) and a
/// synchronous read (<see cref="Current"/>) so the existing sync emit paths (the beacon sink, the share
/// tracker) need no async signature change: a caller warms the cache when it goes interactive, and the
/// emit then reads the cached value with no JS round-trip on the close/unload path.
///
/// <para>
/// Degrades to null when <c>localStorage</c> is unavailable (private mode / blocked / partitioned
/// third-party iframe) — the sink then omits the id and sends an anonId-less event. Over-counting is the
/// accepted direction of error (§3); a missing id never throws.
/// </para>
/// </summary>
public interface IAnonIdProvider
{
/// <summary>
/// The cached anon id, or null if not yet warmed or if storage is unavailable. Synchronous and safe
/// to read from the player close path and the page-unload handler, neither of which can await.
/// </summary>
string? Current { get; }
/// <summary>
/// Warm the cache from <c>localStorage</c> via JS interop (minting on first visit). Idempotent — only
/// the first successful read populates the cache; later calls are no-ops. Best-effort: a JS failure
/// (interop unavailable during prerender, storage blocked) leaves <see cref="Current"/> null and never
/// throws.
/// </summary>
ValueTask EnsureLoadedAsync();
}
+13 -3
View File
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
@@ -14,7 +15,8 @@ namespace DeepDrftPublic.Client.Services;
/// <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; no <c>anonId</c> is sent in wave 16.1.
/// 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.
/// </para>
/// </summary>
public sealed class ShareTracker
@@ -23,13 +25,20 @@ public sealed class ShareTracker
// recommendation — long enough to fold a flurry of repeat copies into one intent.
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
// 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 =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly BeaconInterop _beacon;
private readonly IAnonIdProvider _anonId;
private readonly string _shareUrl;
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
public ShareTracker(BeaconInterop beacon, NavigationManager navigation)
public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
{
_beacon = beacon;
_anonId = anonId;
_shareUrl = $"{navigation.BaseUri}api/event/share";
}
@@ -61,7 +70,8 @@ public sealed class ShareTracker
TargetType = targetType,
TargetKey = targetKey,
Channel = channel,
});
AnonId = _anonId.Current,
}, BeaconJson);
// Fire-and-forget — a dropped share telemetry event is acceptable.
_ = _beacon.SendAsync(_shareUrl, json);