feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ShareTracker
|
||||
{
|
||||
// One event per (target, channel) per this window per session (§1b). 60s matches the spec's
|
||||
// recommendation — long enough to fold a flurry of repeat copies into one intent.
|
||||
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly BeaconInterop _beacon;
|
||||
private readonly string _shareUrl;
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
|
||||
|
||||
public ShareTracker(BeaconInterop beacon, NavigationManager navigation)
|
||||
{
|
||||
_beacon = beacon;
|
||||
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a share unless an identical (target, channel) was recorded within the debounce window.
|
||||
/// Returns true when an event was fired, false when debounced — primarily so tests can assert the
|
||||
/// debounce without reaching into the beacon.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
|
||||
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Debounce-aware record with an injectable <paramref name="now"/> so the 60s window is testable
|
||||
/// without wall-clock waits. The parameterless overload above passes <see cref="DateTimeOffset.UtcNow"/>.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetKey))
|
||||
return false;
|
||||
|
||||
var dedupeKey = $"{targetType}:{targetKey}:{channel}";
|
||||
if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
|
||||
return false;
|
||||
|
||||
_lastSent[dedupeKey] = now;
|
||||
|
||||
var json = JsonSerializer.Serialize(new ShareEventDto
|
||||
{
|
||||
TargetType = targetType,
|
||||
TargetKey = targetKey,
|
||||
Channel = channel,
|
||||
});
|
||||
|
||||
// Fire-and-forget — a dropped share telemetry event is acceptable.
|
||||
_ = _beacon.SendAsync(_shareUrl, json);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user