From e8d3b55284a64cfce19018ed905c15c8f952791b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 24 Apr 2026 02:04:40 +1000 Subject: [PATCH 1/2] feat(audience-diagnostics): expose SDK state as public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six public getters on ImmutableAudience so studios can build debug HUDs, settings-screen displays, and integration tests without reaching into internals: - Initialized: true between Init and Shutdown - CurrentConsent: live consent level, reflects any SetConsent - UserId: last Identify value, null below Full consent or pre-Identify - AnonymousId: persisted id when consent allows tracking, null otherwise - SessionId: current session id, null when no session is active - QueueSize: in-memory plus on-disk event count awaiting send Every getter is safe to call any time — before Init, after Shutdown, from any thread — and returns a safe default when the SDK cannot answer. Values are snapshots that can drift a tick against the background drain thread; fine for display, do not use for invariants. EventQueue gains an internal InMemoryCount property so QueueSize can sum memory and disk without locking the drain thread. Tests cover every getter's lifecycle edges: pre-Init defaults, Init/Shutdown flips, consent downgrade effects, Reset clearing UserId, and QueueSize growing across Init's session_start/game_launch plus an explicit Track. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/ImmutableAudience.cs | 51 +++++++- .../Audience/Runtime/Transport/EventQueue.cs | 6 + .../Tests/Runtime/ImmutableAudienceTests.cs | 111 ++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 08770f274..518aa5986 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -52,6 +52,55 @@ public static class ImmutableAudience // assignments from SetConsent without taking _initLock. private static volatile Session? _session; + // Diagnostic getters. Safe from any thread, any time — return a + // zero-value default (false / null / 0 / ConsentLevel.None) outside + // Init..Shutdown. Values are tick-level snapshots, not invariants. + + public static bool Initialized => _initialized; + + // Persisted value, which may differ from AudienceConfig.Consent if + // a prior session changed it. + public static ConsentLevel CurrentConsent => _state.Level; + + public static string? UserId => _state.UserId; + + // Display-only — Reset and SetConsent(None) wipe it, so it is not + // a stable identifier across sessions. + public static string? AnonymousId + { + get + { + if (!_initialized) return null; + var config = _config; + if (config == null || !_state.Level.CanTrack()) return null; + // PersistentDataPath is validated non-null in Init; compiler can't propagate that. + return Identity.Get(config.PersistentDataPath!); + } + } + + public static string? SessionId => _session?.SessionId; + + // Memory + disk counts are read without holding the drain lock, so + // the sum can drift by a few events. + public static int QueueSize + { + get + { + // Fence off the volatile _initialized load first, matching + // the protocol documented on the reference fields. Without + // this, a weak-memory-order reader could observe + // _initialized=true but _queue/_store still null — the ?. + // short-circuits to 0 in that case, but the inconsistency + // would break the protocol the file claims to follow. + if (!_initialized) return 0; + var queue = _queue; + var store = _store; + var memory = queue?.InMemoryCount ?? 0; + var disk = store?.Count() ?? 0; + return memory + disk; + } + } + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { @@ -720,8 +769,6 @@ internal static void ResetState() } } - internal static ConsentLevel CurrentConsent => _state.Level; - internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync(); // Drives SendBatch without a real timer so the overlapping-tick guard is testable. diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index 66922e3f0..7c457cbb1 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -49,6 +49,12 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) _drainThread.Start(); } + // Approximate count of events currently in the in-memory queue + // awaiting drain to disk. Lock-free read on ConcurrentQueue.Count + // — a snapshot that can race with concurrent enqueue / dequeue. + // Good enough for status-panel display; not an invariant. + internal int InMemoryCount => _memory.Count; + // Enqueues a message dictionary. Lock-free; safe from any thread. // The dictionary is not copied -- callers must not mutate it after // enqueue. Serialisation happens on the drain thread so Track() stays diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 4e3b05fc0..e2a445a05 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -60,6 +60,117 @@ protected override Task SendAsync(HttpRequestMessage reques } } + // ----------------------------------------------------------------- + // Diagnostic getters (Initialized / CurrentConsent / UserId / + // AnonymousId / SessionId / QueueSize) + // ----------------------------------------------------------------- + + [Test] + public void Initialized_FlipsAroundInitAndShutdown() + { + Assert.IsFalse(ImmutableAudience.Initialized, + "Initialized should be false before Init"); + + ImmutableAudience.Init(MakeConfig()); + Assert.IsTrue(ImmutableAudience.Initialized, + "Initialized should flip true after Init"); + + ImmutableAudience.Shutdown(); + Assert.IsFalse(ImmutableAudience.Initialized, + "Initialized should flip back to false after Shutdown"); + } + + [Test] + public void CurrentConsent_ReflectsLatestSetConsent() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + Assert.AreEqual(ConsentLevel.Anonymous, ImmutableAudience.CurrentConsent); + + ImmutableAudience.SetConsent(ConsentLevel.Full); + Assert.AreEqual(ConsentLevel.Full, ImmutableAudience.CurrentConsent); + + ImmutableAudience.SetConsent(ConsentLevel.None); + Assert.AreEqual(ConsentLevel.None, ImmutableAudience.CurrentConsent); + } + + [Test] + public void UserId_Uninitialised_ReturnsNull() + { + Assert.IsNull(ImmutableAudience.UserId); + } + + [Test] + public void UserId_AfterIdentifyAndReset_TracksState() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + Assert.IsNull(ImmutableAudience.UserId, + "UserId should be null until Identify is called"); + + ImmutableAudience.Identify("player-42", IdentityType.Custom); + Assert.AreEqual("player-42", ImmutableAudience.UserId, + "UserId must reflect the most recent Identify call"); + + ImmutableAudience.Reset(); + Assert.IsNull(ImmutableAudience.UserId, + "Reset must clear UserId so the next player is not attributed to the previous one"); + } + + [Test] + public void AnonymousId_ConsentNone_ReturnsNull() + { + // Anonymous identifier is consent-gated: below tracking consent, + // no stable id should leak through the getter. + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + + Assert.IsNull(ImmutableAudience.AnonymousId); + } + + [Test] + public void AnonymousId_ConsentAnonymous_ReturnsPersistedId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + // Track once so Identity.GetOrCreate runs and writes the id file. + ImmutableAudience.Track("warmup_event"); + + var id = ImmutableAudience.AnonymousId; + Assert.IsFalse(string.IsNullOrEmpty(id), + "AnonymousId should return the persisted id once tracking has created one"); + } + + [Test] + public void SessionId_MirrorsSessionLifecycle() + { + Assert.IsNull(ImmutableAudience.SessionId, + "SessionId should be null before Init"); + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + Assert.IsFalse(string.IsNullOrEmpty(ImmutableAudience.SessionId), + "SessionId should be non-null once Init creates a session"); + + ImmutableAudience.Shutdown(); + Assert.IsNull(ImmutableAudience.SessionId, + "SessionId should be null after Shutdown disposes the session"); + } + + [Test] + public void QueueSize_ZeroBeforeInit_GrowsWithEnqueue() + { + Assert.AreEqual(0, ImmutableAudience.QueueSize, + "QueueSize should be 0 before Init"); + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + // Init enqueues session_start + game_launch; those stay + // in-memory until a flush. QueueSize sums memory + disk so the + // pre-flush snapshot must be > 0. + var afterInit = ImmutableAudience.QueueSize; + Assert.Greater(afterInit, 0, + "QueueSize should include session_start and game_launch after Init"); + + ImmutableAudience.Track("explicit_track_event"); + Assert.Greater(ImmutableAudience.QueueSize, afterInit, + "QueueSize should grow when a new event is enqueued"); + } + // ----------------------------------------------------------------- // Unity context provider // ----------------------------------------------------------------- From 3eac28d257a2ac2634515bfe12842fcea443b75f Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 24 Apr 2026 14:40:36 +1000 Subject: [PATCH 2/2] docs(audience-diagnostics): comment the bare diagnostic getters Addresses PR #708 review: adds per-getter comments for `Initialized`, `UserId`, `SessionId` so all six public getters document their own non-obvious semantics, not just the three that did before. The block header above the getters still carries the shared contract (zero-value default outside Init..Shutdown, tick-level snapshot); the new lines only add what a reader cannot derive from the member name. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 518aa5986..bd04abc49 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -56,12 +56,15 @@ public static class ImmutableAudience // zero-value default (false / null / 0 / ConsentLevel.None) outside // Init..Shutdown. Values are tick-level snapshots, not invariants. + // Flipped true at the end of a successful Init, false by Shutdown. public static bool Initialized => _initialized; // Persisted value, which may differ from AudienceConfig.Consent if // a prior session changed it. public static ConsentLevel CurrentConsent => _state.Level; + // Last value passed to Identify(). Cleared by Reset and any consent + // downgrade out of Full. public static string? UserId => _state.UserId; // Display-only — Reset and SetConsent(None) wipe it, so it is not @@ -78,6 +81,7 @@ public static string? AnonymousId } } + // Changes on extended-pause rollover. Also null while consent is None. public static string? SessionId => _session?.SessionId; // Memory + disk counts are read without holding the drain lock, so