fix(telemetry): first-party fetch for play/share, beacon only on unload

Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
This commit is contained in:
daniel-c-harvey
2026-06-26 21:11:43 -04:00
parent ca44979b08
commit 2af0d8650b
16 changed files with 318 additions and 114 deletions
+68 -30
View File
@@ -7,24 +7,38 @@ using Microsoft.JSInterop;
namespace DeepDrftTests;
/// <summary>
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the event payloads emitted by
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
/// no token. Both sinks serialize internally and dispatch through <c>BeaconInterop</c> → the
/// <c>DeepDrftBeacon.send(url, json)</c> JS call, so the assertions capture that JSON string off a fake
/// JS runtime and inspect the <c>anonId</c> field — the same bytes the browser would POST.
/// no token. After the transport-resilience split, normal play closes and shares serialize and POST over
/// the first-party <see cref="IEventPoster"/>, so those assertions capture the JSON off a fake poster.
/// The play sink's unload arm still serializes the same bytes through <c>BeaconInterop</c> →
/// <c>DeepDrftLifecycle.send</c>, asserted off a fake JS runtime — proving both arms carry the id.
/// </summary>
[TestFixture]
public class AnonIdPayloadTests
{
// Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is
// fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored.
// Captures the JSON body of the most recent first-party POST. The poster is fire-and-forget; the
// caller never reads its result.
private sealed class CapturingEventPoster : IEventPoster
{
public string? LastJson { get; private set; }
public Task PostAsync(string url, string json)
{
LastJson = json;
return Task.CompletedTask;
}
}
// Captures the JSON body of the most recent DeepDrftLifecycle.send(url, json) invocation (the unload
// arm). Other interop calls (unload registration) are tolerated and ignored.
private sealed class CapturingJsRuntime : IJSRuntime
{
public string? LastJson { get; private set; }
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json)
if (identifier == "DeepDrftLifecycle.send" && args is { Length: 2 } && args[1] is string json)
LastJson = json;
return ValueTask.FromResult<TValue>(default!);
}
@@ -33,6 +47,12 @@ public class AnonIdPayloadTests
=> InvokeAsync<TValue>(identifier, args);
}
// A no-op poster for the unload-arm test, where the beacon (not the poster) is the asserted transport.
private sealed class NoopEventPoster : IEventPoster
{
public Task PostAsync(string url, string json) => Task.CompletedTask;
}
private sealed class StubAnonIdProvider : IAnonIdProvider
{
public StubAnonIdProvider(string? current) => Current = current;
@@ -64,61 +84,79 @@ public class AnonIdPayloadTests
private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;
// A play emitted while the provider holds a token carries that token in the payload.
private static BeaconPlayEventSink PlaySink(IEventPoster poster, IJSRuntime js, string? anonId)
=> new(poster, new BeaconInterop(js), new StubAnonIdProvider(anonId), new TestNavigationManager());
// --- Play sink, first-party fetch arm (normal close) ---
// A play emitted while the provider holds a token carries that token in the fetch payload.
[Test]
public void PlaySink_WithAnonId_IncludesItInPayload()
public async Task PlaySink_FetchArm_WithAnonId_IncludesItInPayload()
{
var js = new CapturingJsRuntime();
var sink = new BeaconPlayEventSink(
new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager());
var poster = new CapturingEventPoster();
var sink = PlaySink(poster, new CapturingJsRuntime(), "listener-42");
sink.EmitPlay("track-key", PlayBucket.Complete);
await sink.EmitPlayAsync("track-key", PlayBucket.Complete);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42"));
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-42"));
}
// A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
// entirely rather than sending anonId:null.
[Test]
public void PlaySink_WithoutAnonId_OmitsItFromPayload()
public async Task PlaySink_FetchArm_WithoutAnonId_OmitsItFromPayload()
{
var poster = new CapturingEventPoster();
var sink = PlaySink(poster, new CapturingJsRuntime(), null);
await sink.EmitPlayAsync("track-key", PlayBucket.Partial);
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False, "null anonId is omitted from the wire payload");
}
// --- Play sink, sendBeacon arm (page unload) ---
// The unload arm serializes the same payload through sendBeacon, carrying the token too.
[Test]
public void PlaySink_UnloadArm_WithAnonId_IncludesItInPayload()
{
var js = new CapturingJsRuntime();
var sink = new BeaconPlayEventSink(
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
var sink = PlaySink(new NoopEventPoster(), js, "listener-99");
sink.EmitPlay("track-key", PlayBucket.Partial);
sink.EmitPlayOnUnload("track-key", PlayBucket.Complete);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload");
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-99"));
}
// --- Share tracker (always first-party fetch) ---
// A share recorded while the provider holds a token carries it in the payload.
[Test]
public void ShareTracker_WithAnonId_IncludesItInPayload()
{
var js = new CapturingJsRuntime();
var tracker = new ShareTracker(
new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager());
var poster = new CapturingEventPoster();
var tracker = new ShareTracker(poster, new StubAnonIdProvider("listener-7"), new TestNavigationManager());
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7"));
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-7"));
}
// A share recorded with no token omits anonId from the payload.
[Test]
public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
{
var js = new CapturingJsRuntime();
var tracker = new ShareTracker(
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
var poster = new CapturingEventPoster();
var tracker = new ShareTracker(poster, new StubAnonIdProvider(null), new TestNavigationManager());
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False);
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False);
}
// A JS runtime that throws on every call — models localStorage interop being unavailable (private
+48 -2
View File
@@ -13,11 +13,27 @@ namespace DeepDrftTests;
[TestFixture]
public class PlayTrackerTests
{
// Captures emitted plays so assertions read the (key, bucket) the tracker classified.
// Captures emitted plays so assertions read the (key, bucket) the tracker classified. The two arms are
// captured separately so a test can assert which transport a given close selected (fetch vs unload).
// Emitted folds both arms for the floor/bucket assertions that don't care about transport.
private sealed class FakeSink : IPlayEventSink
{
public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket));
public List<(string Key, PlayBucket Bucket)> FetchEmitted { get; } = new();
public List<(string Key, PlayBucket Bucket)> UnloadEmitted { get; } = new();
public Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket)
{
FetchEmitted.Add((trackEntryKey, bucket));
Emitted.Add((trackEntryKey, bucket));
return Task.CompletedTask;
}
public void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket)
{
UnloadEmitted.Add((trackEntryKey, bucket));
Emitted.Add((trackEntryKey, bucket));
}
}
private FakeSink _sink = null!;
@@ -202,4 +218,34 @@ public class PlayTrackerTests
PlaySession("t", duration: 100, highWater: 95);
Assert.That(_sink.Emitted, Has.Count.EqualTo(2));
}
// --- Transport-arm selection (telemetry transport-resilience) ---
// A normal close (organic end / track-switch / stop) emits over the first-party fetch arm — the page
// is alive, so the awaitable HttpClient POST is the heuristic-safe transport.
[Test]
public void Close_NormalClose_EmitsOverFetchArm()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(100);
_tracker.OnProgress(95);
_tracker.Close(); // viaUnload defaults to false
Assert.That(_sink.FetchEmitted, Has.Count.EqualTo(1));
Assert.That(_sink.UnloadEmitted, Is.Empty);
}
// The page-unload close emits over the sendBeacon arm — an awaited fetch would be cancelled as the
// page freezes, so this rare edge keeps the beacon.
[Test]
public void Close_ViaUnload_EmitsOverBeaconArm()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(100);
_tracker.OnProgress(95);
_tracker.Close(viaUnload: true);
Assert.That(_sink.UnloadEmitted, Has.Count.EqualTo(1));
Assert.That(_sink.FetchEmitted, Is.Empty);
}
}
+7 -13
View File
@@ -1,33 +1,27 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): the per-(target,channel)
/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires
/// through a beacon that wraps <see cref="IJSRuntime"/>; the tests use a no-op JS runtime (the send is
/// through the first-party <see cref="IEventPoster"/>; the tests use a no-op poster (the POST is
/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the
/// recorder returns — true when an event fired, false when debounced.
/// </summary>
[TestFixture]
public class ShareTrackerTests
{
// sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that
// returns default for any invocation is sufficient to exercise the debounce logic.
private sealed class NoopJsRuntime : IJSRuntime
// The first-party POST is fire-and-forget; the tracker never reads the result, so a no-op poster that
// completes immediately is sufficient to exercise the debounce logic.
private sealed class NoopEventPoster : IEventPoster
{
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
=> ValueTask.FromResult<TValue>(default!);
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> ValueTask.FromResult<TValue>(default!);
public Task PostAsync(string url, string json) => Task.CompletedTask;
}
// Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL.
// Minimal NavigationManager so the tracker can compose the (unused-in-test) event URL.
private sealed class TestNavigationManager : NavigationManager
{
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
@@ -49,7 +43,7 @@ public class ShareTrackerTests
[SetUp]
public void SetUp()
=> _tracker = new ShareTracker(
new BeaconInterop(new NoopJsRuntime()),
new NoopEventPoster(),
new StubAnonIdProvider("anon-1"),
new TestNavigationManager());