feat(phase-16.3): light up anonId unique-listener layer

Mint a first-party localStorage anonId, thread it onto play/share beacons,
persist it via EventController, and add all-time distinct-listener counts
(site/track/release). Storage columns + indexes already existed from 16.1.
This commit is contained in:
daniel-c-harvey
2026-06-19 14:37:55 -04:00
parent ebbaa3f84f
commit c084efa78e
16 changed files with 680 additions and 12 deletions
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
[Inject] public required BeaconInterop Beacon { get; set; }
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
@@ -50,6 +51,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
_queueService.Attach(_audioPlayerService);
}
/// <summary>
/// Warm the anon-id cache once the provider is interactive (Phase 16 wave 16.3). Done here, after the
/// first render, because the localStorage read is JS interop — not available during prerender. By the
/// time any play session closes and the sink reads <c>AnonId.Current</c>, the cache is populated; a
/// play that somehow closes before this completes simply sends no anonId (acceptable over-count). The
/// provider is the natural warm point: it is mounted in MainLayout, so it goes interactive on every
/// page the player can play from.
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await AnonId.EnsureLoadedAsync();
}
/// <summary>
/// Dispose the player on unmount so the JS setInterval driving progress
/// callbacks no longer holds a DotNetObjectReference into a destroyed
@@ -30,6 +30,7 @@ public partial class SharePopover : ComponentBase, IDisposable
[Inject] public required NavigationManager Navigation { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
[Inject] public required ShareTracker ShareTracker { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
private bool IsReleaseMode => ReleaseEntryKey is not null;
@@ -65,7 +66,15 @@ public partial class SharePopover : ComponentBase, IDisposable
? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!)
: EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!);
private void Toggle() => _open = !_open;
private async Task Toggle()
{
_open = !_open;
// Warm the anon-id cache when the popover opens (wave 16.3) so a copy-share fired moments later
// reads a populated AnonId.Current. Idempotent and best-effort — if it fails the share simply
// carries no anonId. Opening is interactive, so the localStorage interop is available here.
if (_open)
await AnonId.EnsureLoadedAsync();
}
private void Close() => _open = false;
@@ -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);
+4 -1
View File
@@ -38,8 +38,11 @@ public static class Startup
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
// share tracker fire events through it. The play tracker itself is NOT registered — the player
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
// is scoped so its per-(target,channel) debounce memory lives for the session.
// is scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider
// (wave 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the
// session, warmed when a surface goes interactive (the player provider, the share popover).
services.AddScoped<BeaconInterop>();
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
services.AddScoped<ShareTracker>();
}