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;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftPublic.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies the anonymous telemetry write endpoints (<c>POST api/event/play</c> / <c>api/event/share</c>)
|
||||
/// to DeepDrftAPI so the WASM client never makes a cross-origin request (Phase 16 §2.2). Mirrors
|
||||
/// <see cref="TrackProxyController"/>'s idiom — the named <c>"DeepDrft.API"</c> client forwards the
|
||||
/// request upstream — but for a POST write: the small JSON body is buffered and relayed verbatim, and
|
||||
/// the upstream status (202 on success, 4xx on a rejected payload, 429 on rate limit) passes back so the
|
||||
/// beacon's fire-and-forget contract is preserved end to end. SSR never posts these — they originate
|
||||
/// from the browser player/share surfaces only.
|
||||
/// </summary>
|
||||
// A sendBeacon POST cannot attach a Blazor antiforgery token, so the telemetry write routes opt out
|
||||
// explicitly. They are anonymous, idempotent-enough fire-and-forget logging — there is no
|
||||
// state-changing user action to protect with CSRF tokens, and the upstream rate-limits by IP.
|
||||
[ApiController]
|
||||
[Route("api/event")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class EventProxyController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _upstream;
|
||||
private readonly ILogger<EventProxyController> _logger;
|
||||
|
||||
public EventProxyController(IHttpClientFactory httpClientFactory, ILogger<EventProxyController> logger)
|
||||
{
|
||||
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Proxies a play event upstream. Body is opaque JSON — validated by DeepDrftAPI, not here.</summary>
|
||||
[HttpPost("play")]
|
||||
public Task<ActionResult> ForwardPlay(CancellationToken ct = default) => Forward("api/event/play", ct);
|
||||
|
||||
/// <summary>Proxies a share event upstream.</summary>
|
||||
[HttpPost("share")]
|
||||
public Task<ActionResult> ForwardShare(CancellationToken ct = default) => Forward("api/event/share", ct);
|
||||
|
||||
private async Task<ActionResult> Forward(string upstreamPath, CancellationToken ct)
|
||||
{
|
||||
// Buffer the small JSON body and relay it verbatim. Reading the raw body keeps the proxy
|
||||
// transparent — it does not deserialize or re-shape the payload, just forwards it.
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||
{
|
||||
body = await reader.ReadToEndAsync(ct);
|
||||
}
|
||||
|
||||
using var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage upstream;
|
||||
try
|
||||
{
|
||||
upstream = await _upstream.PostAsync(upstreamPath, content, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Upstream call to DeepDrftAPI {Path} failed", upstreamPath);
|
||||
return StatusCode(502, "Upstream unavailable");
|
||||
}
|
||||
|
||||
// Relay the upstream status as-is. Telemetry is fire-and-forget; the beacon never reads the
|
||||
// body, so there is nothing to relay beyond the code (202 / 400 / 429 / 5xx).
|
||||
using (upstream)
|
||||
{
|
||||
return StatusCode((int)upstream.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user