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