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:
daniel-c-harvey
2026-06-19 12:59:00 -04:00
parent 1931574ad4
commit dbd90ee52a
35 changed files with 2460 additions and 2 deletions
+18
View File
@@ -0,0 +1,18 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.DTOs;
/// <summary>
/// Wire payload for <c>POST api/event/play</c> (Phase 16 §2.2 / §4.3). The client sends only what it
/// cheaply knows — the track key and the client-computed completion bucket; the server resolves the
/// release. No duration or raw position is transmitted (a privacy plus — only a coarse bucket leaves
/// the browser). <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
/// </summary>
public class PlayEventDto
{
public string? TrackEntryKey { get; set; }
public PlayBucket Bucket { get; set; }
public string? AnonId { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.DTOs;
/// <summary>
/// Wire payload for <c>POST api/event/share</c> (Phase 16 §2.2 / §4.3). The popover knows the target
/// and channel at the point of the action, so the payload is self-describing — no server-side resolution.
/// <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
/// </summary>
public class ShareEventDto
{
public ShareTargetType TargetType { get; set; }
public string? TargetKey { get; set; }
public ShareChannel Channel { get; set; }
public string? AnonId { get; set; }
}
+28
View File
@@ -0,0 +1,28 @@
namespace DeepDrftModels.Entities;
/// <summary>
/// Incremental rollup of play counts per track (Phase 16 §4.1 / D6). One row per track, bumped inside
/// the same transaction that appends the <see cref="PlayEvent"/> — no background aggregation job. The
/// home card and per-target reads sum these instead of <c>COUNT(*)</c>-ing the event log on every
/// landing. Release totals are <em>derived</em> (D4) by summing the counters of the release's tracks,
/// so there is no separate release-counter row — this keeps the rollup normalized at one row per track.
/// </summary>
public class PlayCounter
{
public long Id { get; set; }
/// <summary>The track these counts belong to (SQL id). Unique — one counter row per track.</summary>
public long TrackId { get; set; }
/// <summary>Count of plays that ended in the <c>Partial</c> bucket (&lt; 30%).</summary>
public long PartialCount { get; set; }
/// <summary>Count of plays that ended in the <c>Sampled</c> bucket (30%80%).</summary>
public long SampledCount { get; set; }
/// <summary>Count of plays that ended in the <c>Complete</c> bucket (&gt; 80%).</summary>
public long CompleteCount { get; set; }
/// <summary>Total plays for the track — the sum of the three bucket counts (headline figure).</summary>
public long TotalPlays => PartialCount + SampledCount + CompleteCount;
}
+36
View File
@@ -0,0 +1,36 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.Entities;
/// <summary>
/// Append-only log row for one recorded play (Phase 16 §4.2). Written once at session close, after the
/// engagement floor is crossed; never updated or deleted. Deliberately NOT a <c>BaseEntity</c>: events
/// have no soft-delete lifecycle, no <c>UpdatedAt</c> — they are immutable facts. The release link is
/// resolved server-side from the track key at write time (§2.3 / D4) and stored here so release-total
/// plays are a cheap sum over this column.
/// </summary>
public class PlayEvent
{
public long Id { get; set; }
/// <summary>The played track's vault entry key (the only target the client sends).</summary>
public required string TrackEntryKey { get; set; }
/// <summary>
/// The owning release's SQL id, resolved from <see cref="TrackEntryKey"/> at write time. Null when
/// the track is loose (no release) or the key did not resolve to a live track at write time.
/// </summary>
public long? ReleaseId { get; set; }
/// <summary>The completion bucket computed client-side from the high-water fraction (§1a / D1).</summary>
public PlayBucket Bucket { get; set; }
/// <summary>
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; nothing writes it in wave
/// 16.1 — the client sends none and the column stays NULL. Wave 16.3 lights it up for the
/// distinct-listener count.
/// </summary>
public string? AnonId { get; set; }
public DateTime CreatedAt { get; set; }
}
+34
View File
@@ -0,0 +1,34 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.Entities;
/// <summary>
/// Append-only log row for one recorded share (Phase 16 §4.2). Written once per share action that
/// survives the per-(target,channel) client debounce; never updated or deleted. Like <see cref="PlayEvent"/>
/// it is deliberately NOT a <c>BaseEntity</c> — an immutable fact with no soft-delete lifecycle. Shares
/// carry their target directly (the popover knows track vs. release), so no server-side resolution step.
/// </summary>
public class ShareEvent
{
public long Id { get; set; }
/// <summary>Whether the share targets a track or a release.</summary>
public ShareTargetType TargetType { get; set; }
/// <summary>
/// The shared target's key: a track's vault <c>EntryKey</c> or a release's public <c>EntryKey</c>,
/// selected by <see cref="TargetType"/>. Stored as the opaque key, not resolved to a SQL id — the
/// share metric is a simple per-target tally and needs no join in wave 16.1.
/// </summary>
public required string TargetKey { get; set; }
/// <summary>The channel the share was performed through (link vs. embed).</summary>
public ShareChannel Channel { get; set; }
/// <summary>
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; unused in wave 16.1.
/// </summary>
public string? AnonId { get; set; }
public DateTime CreatedAt { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace DeepDrftModels.Enums;
/// <summary>
/// Completion bucket for a recorded play (Phase 16 §1a / D1). The three buckets are exhaustive and
/// non-overlapping, classified by the high-water playback fraction reached before the session closed:
/// <c>Partial</c> [0, 30%), <c>Sampled</c> [30%, 80%], <c>Complete</c> (80%, 100%]. The headline
/// "Plays" figure is the sum of all three — every started listen that crosses the engagement floor
/// is a play; the buckets are the texture beneath it.
///
/// Serialized as its string name on the wire — the converter on the type makes the
/// client to proxy to API JSON contract string-based regardless of host serializer config.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PlayBucket>))]
public enum PlayBucket
{
/// <summary>Reached &lt; 30% of duration — a skip or a brief partial listen (still past the floor).</summary>
Partial,
/// <summary>Reached 30%80% of duration — a real listen that was neither a skip nor a finish.</summary>
Sampled,
/// <summary>Reached &gt; 80% of duration — effectively a finished listen.</summary>
Complete
}
+33
View File
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace DeepDrftModels.Enums;
/// <summary>
/// The channel a share was performed through (Phase 16 §1b). Today both originate from
/// <c>SharePopover</c>'s clipboard actions; a future native/Web-Share button would add a channel
/// without reshaping the metric. Serialized as its string name on the wire.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ShareChannel>))]
public enum ShareChannel
{
/// <summary>Copy-link — the canonical track or release URL placed on the clipboard.</summary>
Link,
/// <summary>Copy-embed — the <c>&lt;iframe&gt;</c> snippet for the single-track FramePlayer.</summary>
Embed
}
/// <summary>
/// What a share targets (Phase 16 §1b). Tracks and releases are both shareable; the popover knows
/// which it is at the point of the action, so no server-side resolution is needed for shares.
/// Serialized as its string name on the wire.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ShareTargetType>))]
public enum ShareTargetType
{
/// <summary>The share targets a single track, addressed by its vault <c>EntryKey</c>.</summary>
Track,
/// <summary>The share targets a release, addressed by its public <c>EntryKey</c>.</summary>
Release
}