From 128504b7d597e134f9550e9d7cb94967a49730ec Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 16:04:09 +1000 Subject: [PATCH 1/3] feat(audience-unity): add Unity integration layer for context and lifecycle (SDK-146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudienceUnityHooks installs on SubsystemRegistration and wires: - DefaultPersistentDataPathProvider from Application.persistentDataPath - LaunchContextProvider with DeviceCollector's one-shot game_launch fields (platform, version, buildGuid, unityVersion, osFamily, deviceModel, gpu*, cpu*, ramMb, screenDpi) - ContextProvider with userAgent / locale / timezone / screen, merged into every outgoing message.context - Application.quitting -> ImmutableAudience.Shutdown - UnityLifecycleBridge forwards OnApplicationPause / OnApplicationFocus to ImmutableAudience.OnPause / OnResume New Unity-layer files (Runtime/Unity/): - DeviceCollector.cs — IL2CPP-safe SystemInfo / Screen / Application readers. Strings capped at 256 chars to mirror the Web SDK identifier cap (core/src/validation.ts MAX_STRING_LENGTH) - UnityLifecycleBridge.cs — lifecycle forwarder Core changes in ImmutableAudience: - LaunchContextProvider / ContextProvider typed as Func>? so the cached snapshot cannot be mutated by any downstream reader - MergeUnityContext merges ContextProvider output into every outgoing message.context before EnqueueChecked; throws and null returns are swallowed so a misbehaving layer cannot drop events Build and test infrastructure: - Directory.Build.props redirects bin/obj to repo-root artifacts/ so dotnet output doesn't leak into Unity's asset importer scan path - ConstantsTests walks up from the test binary to find the package root so the Directory.Build.props redirect doesn't break it - com.immutable.audience / .unity asmdefs updated - link.xml preserves Unity hooks from IL2CPP stripping - .gitignore adds artifacts/ Tests: - ContextProvider_Set / _Throwing / _ReturnsNull pin the merge + swallow behaviour Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 10 ++ src/Packages/Audience/Directory.Build.props | 21 +++ src/Packages/Audience/Runtime/Core/Session.cs | 35 +---- .../Audience/Runtime/ImmutableAudience.cs | 51 +++++-- .../Runtime/Unity/AudienceUnityHooks.cs | 24 +-- .../Audience/Runtime/Unity/DeviceCollector.cs | 102 +++++++++++++ .../Runtime/Unity/UnityLifecycleBridge.cs | 49 ++++++ .../Unity/com.immutable.audience.unity.asmdef | 2 +- .../Runtime/com.immutable.audience.asmdef | 2 +- .../Audience/Tests/Runtime/ConstantsTests.cs | 29 +++- .../Tests/Runtime/Core/SessionTests.cs | 140 ++---------------- .../Tests/Runtime/ImmutableAudienceTests.cs | 91 ++++++++++++ src/Packages/Audience/link.xml | 1 + 13 files changed, 366 insertions(+), 191 deletions(-) create mode 100644 src/Packages/Audience/Directory.Build.props create mode 100644 src/Packages/Audience/Runtime/Unity/DeviceCollector.cs create mode 100644 src/Packages/Audience/Runtime/Unity/UnityLifecycleBridge.cs diff --git a/.gitignore b/.gitignore index 3ee3fd39b..9acbe4148 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,16 @@ [Ll]ogs/ [Uu]ser[Ss]ettings/ +# dotnet build outputs redirected here via Directory.Build.props files +# so bin/obj don't sit inside Unity package folders and get scanned. +/artifacts/ + +# IDE and MSBuild extensibility sidecars Unity emits .meta for when the +# files land inside the package root. +*.DotSettings.user.meta +Directory.Build.props.meta +Directory.Build.targets.meta + # MemoryCaptures can get excessive in size. # They also could contain extremely sensitive data /[Mm]emoryCaptures/ diff --git a/src/Packages/Audience/Directory.Build.props b/src/Packages/Audience/Directory.Build.props new file mode 100644 index 000000000..7a4d7a676 --- /dev/null +++ b/src/Packages/Audience/Directory.Build.props @@ -0,0 +1,21 @@ + + + + $(MSBuildThisFileDirectory)../../../artifacts/$(MSBuildProjectName)/bin/ + $(MSBuildThisFileDirectory)../../../artifacts/$(MSBuildProjectName)/obj/ + + diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index a0f4414a9..1d16566fe 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -29,7 +29,6 @@ internal sealed class Session : IDisposable internal const int PauseTimeoutMs = 30_000; private readonly TrackDelegate _track; - private readonly Func>? _performanceSnapshot; private readonly Func _getUtcNow; private readonly int _heartbeatIntervalMs; private readonly object _lock = new object(); @@ -48,16 +47,13 @@ internal string? SessionId get { lock (_lock) return _sessionId; } } - // track: fires session events. performanceSnapshot: merges fps/memory - // into heartbeats (null on non-Unity). getUtcNow/heartbeatIntervalMs: test seams. + // track: fires session events. getUtcNow/heartbeatIntervalMs: test seams. internal Session( TrackDelegate track, - Func>? performanceSnapshot = null, Func? getUtcNow = null, int heartbeatIntervalMs = HeartbeatIntervalMs) { _track = track ?? throw new ArgumentNullException(nameof(track)); - _performanceSnapshot = performanceSnapshot; _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); _heartbeatIntervalMs = heartbeatIntervalMs; } @@ -252,24 +248,13 @@ internal void OnHeartbeat() duration = ComputeEngagedSecondsLocked(); } - // Build outside _lock so snapshot + track don't re-enter. + // Build outside _lock so track doesn't re-enter. var properties = new Dictionary { ["sessionId"] = sessionId, ["durationSec"] = duration }; - var perf = SafePerformanceSnapshot(); - if (perf != null) - { - foreach (var kv in perf) - { - // Don't let the provider clobber core fields. - if (properties.ContainsKey(kv.Key)) continue; - properties[kv.Key] = kv.Value; - } - } - SafeTrack("session_heartbeat", properties); } @@ -289,22 +274,6 @@ private void SafeTrack(string eventName, Dictionary properties) } } - // Stops exceptions from the studio-supplied snapshot callback from - // reaching the background timer. - private Dictionary? SafePerformanceSnapshot() - { - if (_performanceSnapshot == null) return null; - try - { - return _performanceSnapshot(); - } - catch (Exception ex) - { - Log.Warn($"Session: performance snapshot threw {ex.GetType().Name}. Heartbeat ships without performance fields."); - return null; - } - } - // Stops the timer and waits for the in-flight callback. Runs outside // _lock (OnHeartbeat re-enters). 1s budget (quits must not hang). Warns on timeout. private void DrainHeartbeatTimer() diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 0d96df933..45d84239b 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -38,12 +38,13 @@ public static class ImmutableAudience // Gate against overlapping timer ticks (Timer callbacks run on independent ThreadPool threads). private static int _sendInFlight; - // AudienceUnityHooks sets these at SubsystemRegistration. - // DefaultPersistentDataPathProvider fills PersistentDataPath from - // Application.persistentDataPath. LaunchContextProvider supplies - // Unity context for game_launch without Core referencing UnityEngine. - internal static Func? DefaultPersistentDataPathProvider; - internal static Func>? LaunchContextProvider; + // volatile: assigned on the Unity main thread at SubsystemRegistration, + // read from the drain thread in Track / Identify paths. + // The assignments happen before any event can fire in practice, but + // volatile documents the cross-thread publish contract explicitly. + internal static volatile Func? DefaultPersistentDataPathProvider; + internal static volatile Func>? LaunchContextProvider; + internal static volatile Func>? ContextProvider; // Active session. Created at Init (or on upgrade from None) and disposed // on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see @@ -701,9 +702,7 @@ public static void Shutdown() // Internal — shared with tests and AudienceUnityHooks // ----------------------------------------------------------------- - // Shuts down (if initialised) and clears per-session state. Used on - // test teardown and Unity SubsystemRegistration to survive "disable - // domain reload". LaunchContextProvider is re-assigned by AudienceUnityHooks. + // Providers reassigned by SubsystemRegistration. internal static void ResetState() { // Shutdown manages its own serialisation and releases _initLock before @@ -743,6 +742,7 @@ internal static void ResetState() // Anonymous the userId is stripped. private static void EnqueueTrack(Dictionary? msg) { + MergeUnityContext(msg); _queue?.EnqueueChecked(msg, m => { var state = _state; @@ -756,10 +756,41 @@ private static void EnqueueTrack(Dictionary? msg) // Identify / Alias require Full; drop if consent has downgraded. private static void EnqueueIdentity(Dictionary? msg) { + MergeUnityContext(msg); _queue?.EnqueueChecked(msg, m => _state.Level == ConsentLevel.Full ? m : null); } + private static void MergeUnityContext(Dictionary? msg) + { + if (msg == null) return; + + var provider = ContextProvider; + if (provider == null) return; + + IReadOnlyDictionary? extra; + try + { + extra = provider(); + } + catch (Exception ex) + { + Log.Warn($"ContextProvider threw {ex.GetType().Name}: {ex.Message}. " + + "Event ships with base context only."); + return; + } + if (extra == null) return; + + if (!(msg.TryGetValue("context", out var ctxObj) && ctxObj is Dictionary ctx)) + { + ctx = new Dictionary(); + msg["context"] = ctx; + } + + foreach (var kv in extra) + ctx[kv.Key] = kv.Value; + } + private static void SendBatch() { // If a previous send is still running, skip this one. That send @@ -825,7 +856,7 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt var provider = LaunchContextProvider; if (provider != null) { - Dictionary? unityContext = null; + IReadOnlyDictionary? unityContext = null; try { unityContext = provider(); } catch (Exception ex) { diff --git a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs index 188f4485a..64e1800f1 100644 --- a/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs +++ b/src/Packages/Audience/Runtime/Unity/AudienceUnityHooks.cs @@ -1,6 +1,7 @@ #nullable enable using System.Collections.Generic; +using System.Collections.ObjectModel; using UnityEngine; namespace Immutable.Audience.Unity @@ -10,26 +11,25 @@ internal static class AudienceUnityHooks [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void Install() { - // Clear surviving statics before re-wiring in case "disable domain reload" kept them alive. ImmutableAudience.ResetState(); - // -= then += so repeat SubsystemRegistration cycles don't stack subscriptions. + // Avoid stacked subscriptions on reload. Application.quitting -= ImmutableAudience.Shutdown; Application.quitting += ImmutableAudience.Shutdown; ImmutableAudience.DefaultPersistentDataPathProvider = () => Application.persistentDataPath; - ImmutableAudience.LaunchContextProvider = BuildLaunchContext; + + // Captured once on main thread; ReadOnlyDictionary blocks downstream mutation. + IReadOnlyDictionary launchProps = + new ReadOnlyDictionary(DeviceCollector.CollectGameLaunchProperties()); + IReadOnlyDictionary contextProps = + new ReadOnlyDictionary(DeviceCollector.CollectContext()); + ImmutableAudience.LaunchContextProvider = () => launchProps; + ImmutableAudience.ContextProvider = () => contextProps; + + UnityLifecycleBridge.EnsureExists(); if (Log.Writer == null) Log.Writer = Debug.Log; } - - private static Dictionary BuildLaunchContext() => - new Dictionary - { - ["platform"] = Application.platform.ToString(), - ["version"] = Application.version, - ["buildGuid"] = Application.buildGUID, - ["unityVersion"] = Application.unityVersion, - }; } } diff --git a/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs b/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs new file mode 100644 index 000000000..d9926770c --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs @@ -0,0 +1,102 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using UnityEngine; + +namespace Immutable.Audience.Unity +{ + internal static class DeviceCollector + { + internal static Dictionary CollectContext() + { + // 256-char cap mirrors Web SDK's identifier truncation. + var ctx = new Dictionary + { + ["userAgent"] = Truncate(SystemInfo.operatingSystem, 256), + }; + + var timezone = SafeTimezone(); + if (timezone != null) ctx["timezone"] = Truncate(timezone, 256); + + var locale = LocaleString(); + if (locale != null) ctx["locale"] = Truncate(locale, 256); + + var screen = TryResolveScreenString(); + if (screen != null) ctx["screen"] = Truncate(screen, 256); + + return ctx; + } + + private static string? TryResolveScreenString() + { + var resolution = Screen.currentResolution; + int width = resolution.width; + int height = resolution.height; + + if (width <= 0 || height <= 0) + { + width = Screen.width; + height = Screen.height; + } + + if (width <= 0 || height <= 0) return null; + return $"{width}x{height}"; + } + + internal static Dictionary CollectGameLaunchProperties() + { + var props = new Dictionary + { + ["platform"] = Application.platform.ToString(), + ["version"] = Truncate(Application.version, 256), + ["buildGuid"] = Truncate(Application.buildGUID, 256), + ["unityVersion"] = Truncate(Application.unityVersion, 256), + ["osFamily"] = SystemInfo.operatingSystemFamily.ToString(), + ["deviceModel"] = Truncate(SystemInfo.deviceModel, 256), + ["gpu"] = Truncate(SystemInfo.graphicsDeviceName, 256), + ["gpuVendor"] = Truncate(SystemInfo.graphicsDeviceVendor, 256), + ["cpu"] = Truncate(SystemInfo.processorType, 256), + ["cpuCores"] = SystemInfo.processorCount, + ["ramMb"] = SystemInfo.systemMemorySize, + }; + + // Screen.dpi can be 0 on some Linux WMs. + var dpi = (int)Screen.dpi; + if (dpi > 0) props["screenDpi"] = dpi; + + return props; + } + + private static string? LocaleString() + { + var culture = CultureInfo.CurrentCulture; + if (!string.IsNullOrEmpty(culture?.Name)) + return culture.Name; + return null; + } + + private static string? SafeTimezone() + { + try + { + return TimeZoneInfo.Local.Id; + } + catch (Exception) + { + return null; + } + } + + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s) || s.Length <= max) return s; + // Step back one if the cut would split a surrogate pair — leaving + // a lone high-surrogate produces invalid UTF-16 on the wire. + var cut = max; + if (char.IsHighSurrogate(s[cut - 1])) cut--; + return s.Substring(0, cut); + } + } +} diff --git a/src/Packages/Audience/Runtime/Unity/UnityLifecycleBridge.cs b/src/Packages/Audience/Runtime/Unity/UnityLifecycleBridge.cs new file mode 100644 index 000000000..919a58860 --- /dev/null +++ b/src/Packages/Audience/Runtime/Unity/UnityLifecycleBridge.cs @@ -0,0 +1,49 @@ +#nullable enable + +using UnityEngine; + +namespace Immutable.Audience.Unity +{ + internal sealed class UnityLifecycleBridge : MonoBehaviour + { + // Volatile: SubsystemRegistration reset vs EnsureExists fence. + private static volatile UnityLifecycleBridge? _instance; + + // Drop stale GameObject pointer after Fast Enter Play Mode. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStatics() + { + _instance = null; + } + + internal static void EnsureExists() + { + if (_instance != null) return; + + var go = new GameObject("[ImmutableAudience.LifecycleBridge]"); + go.hideFlags = HideFlags.HideAndDontSave; + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + } + + private void OnApplicationPause(bool paused) + { + if (paused) ImmutableAudience.OnPause(); + else ImmutableAudience.OnResume(); + } + +#if !UNITY_ANDROID && !UNITY_IOS + // Desktop only — mobile focus events fire spuriously (soft keyboard, notifications). + private void OnApplicationFocus(bool hasFocus) + { + if (!hasFocus) ImmutableAudience.OnPause(); + else ImmutableAudience.OnResume(); + } +#endif + + private void OnDestroy() + { + if (_instance == this) _instance = null; + } + } +} diff --git a/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef b/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef index 4b186e12c..ca4fa4a56 100644 --- a/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef +++ b/src/Packages/Audience/Runtime/Unity/com.immutable.audience.unity.asmdef @@ -2,7 +2,7 @@ "name": "Immutable.Audience.Unity", "rootNamespace": "Immutable.Audience.Unity", "references": ["Immutable.Audience.Runtime"], - "includePlatforms": ["Editor","LinuxStandalone64","macOSStandalone","WindowsStandalone64"], + "includePlatforms": ["Editor", "macOSStandalone", "WindowsStandalone64"], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, diff --git a/src/Packages/Audience/Runtime/com.immutable.audience.asmdef b/src/Packages/Audience/Runtime/com.immutable.audience.asmdef index 1b3ca3962..e0a77c072 100644 --- a/src/Packages/Audience/Runtime/com.immutable.audience.asmdef +++ b/src/Packages/Audience/Runtime/com.immutable.audience.asmdef @@ -2,7 +2,7 @@ "name": "Immutable.Audience.Runtime", "rootNamespace": "Immutable.Audience", "references": [], - "includePlatforms": ["Editor","LinuxStandalone64","macOSStandalone","WindowsStandalone64"], + "includePlatforms": ["Editor", "macOSStandalone", "WindowsStandalone64"], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs index b0af0ea8d..f72851810 100644 --- a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs @@ -23,11 +23,30 @@ public void LibraryVersion_MatchesPackageJson() private static string ReadPackageJson() { - var testDir = TestContext.CurrentContext.TestDirectory; - // Tests/bin/Debug/net8.0/ → Tests/ → Audience/package.json - var packagePath = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "package.json")); - Assert.IsTrue(File.Exists(packagePath), $"package.json not found at {packagePath}"); - return File.ReadAllText(packagePath); + // Walk up from the test binary location looking for the Audience + // package directory. Originally this hard-coded four "../" hops + // which only worked when bin/ sat inside Tests/. Directory.Build + // .props redirects bin/ to the repo-root /artifacts/ folder so + // dotnet build outputs don't leak into Unity's scan path — the + // relative walk no longer resolves to the package. Searching + // upward is robust against either layout. + var current = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (current != null) + { + var candidate = Path.Combine(current.FullName, "src", "Packages", "Audience", "package.json"); + if (File.Exists(candidate)) return File.ReadAllText(candidate); + + // Also try the direct-inside case (package root itself is + // the ancestor), which handles consuming-project layouts + // that embed the package without the src/Packages prefix. + var direct = Path.Combine(current.FullName, "package.json"); + if (File.Exists(direct) && current.Name == "Audience") return File.ReadAllText(direct); + + current = current.Parent; + } + + throw new FileNotFoundException( + $"package.json not found by walking up from {TestContext.CurrentContext.TestDirectory}"); } } } diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs index 0024d5ac0..b8913741d 100644 --- a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs @@ -57,7 +57,7 @@ public void Start_GeneratesUniqueSessionId() public void End_FiresSessionEnd_WithDuration() { var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: () => now); + using var session = new Session(MockTrack, getUtcNow: () => now); session.Start(); now = now.AddSeconds(2); session.End(); @@ -126,87 +126,6 @@ void Track(string name, Dictionary props) } } - [Test] - public void Heartbeat_WithoutPerformanceSnapshot_OnlyCarriesCoreProperties() - { - using var session = new Session(MockTrack); - session.Start(); - - session.OnHeartbeat(); - - var beat = _events.Last(e => e.name == "session_heartbeat"); - CollectionAssert.AreEquivalent( - new[] { "sessionId", "durationSec" }, - beat.props.Keys); - } - - [Test] - public void Heartbeat_MergesPerformanceSnapshotProperties() - { - Func> snapshot = () => new Dictionary - { - ["fpsAvg"] = 58.4, - ["fpsMin"] = 42.1, - ["memoryUsedMb"] = 512L, - ["memoryReservedMb"] = 768L, - }; - using var session = new Session(MockTrack, snapshot); - session.Start(); - - session.OnHeartbeat(); - - var beat = _events.Last(e => e.name == "session_heartbeat"); - Assert.AreEqual(58.4, beat.props["fpsAvg"]); - Assert.AreEqual(42.1, beat.props["fpsMin"]); - Assert.AreEqual(512L, beat.props["memoryUsedMb"]); - Assert.AreEqual(768L, beat.props["memoryReservedMb"]); - Assert.IsTrue(beat.props.ContainsKey("sessionId")); - Assert.IsTrue(beat.props.ContainsKey("durationSec")); - } - - [Test] - public void Heartbeat_SnapshotCannotOverwriteCoreFields() - { - // Core fields (sessionId, duration) are owned by Session. A - // provider that returns a dictionary containing either key must - // not be able to clobber the wire values — otherwise a buggy - // studio-side snapshot could silently rewrite session identity - // or engagement arithmetic on every heartbeat. Sabotage: removing - // the ContainsKey guard lets "spoofed-id" and 99999L land on the - // wire and both assertions below fail. - Func> snapshot = () => new Dictionary - { - ["sessionId"] = "spoofed-id", - ["durationSec"] = 99999L, - ["fpsAvg"] = 60.0, - }; - using var session = new Session(MockTrack, snapshot); - session.Start(); - - session.OnHeartbeat(); - - var beat = _events.Last(e => e.name == "session_heartbeat"); - Assert.AreNotEqual("spoofed-id", (string)beat.props["sessionId"], - "snapshot must not overwrite Session-owned sessionId"); - Assert.AreNotEqual(99999L, (long)beat.props["durationSec"], - "snapshot must not overwrite Session-owned duration"); - Assert.AreEqual(60.0, beat.props["fpsAvg"], - "non-colliding snapshot fields should still merge"); - } - - [Test] - public void Heartbeat_SnapshotReturningNull_DoesNotThrowAndOmitsFields() - { - Func> snapshot = () => null; - using var session = new Session(MockTrack, snapshot); - session.Start(); - - session.OnHeartbeat(); - - var beat = _events.Last(e => e.name == "session_heartbeat"); - Assert.IsFalse(beat.props.ContainsKey("fpsAvg")); - } - // ----------------------------------------------------------------- // Pause / Resume // ----------------------------------------------------------------- @@ -215,7 +134,7 @@ public void Heartbeat_SnapshotReturningNull_DoesNotThrowAndOmitsFields() public void Pause_ThenResume_ShortPause_ContinuesSession() { var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: () => now); + using var session = new Session(MockTrack, getUtcNow: () => now); session.Start(); var originalId = session.SessionId; @@ -234,7 +153,7 @@ public void Pause_ThenResume_LongPause_StartsNewSession() // Uses the injected clock to jump past the 30-second threshold // without sleeping. var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: () => now); + using var session = new Session(MockTrack, getUtcNow: () => now); session.Start(); var id1 = session.SessionId; @@ -263,7 +182,7 @@ public void Pause_CalledTwice_SecondCallIsNoOp() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(5); @@ -314,7 +233,7 @@ public void Resume_NegativePauseDuration_ClampsAccumulatorToZero() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(10); // 10 s engaged @@ -351,7 +270,7 @@ public void End_ClockRewindsSinceStart_ClampsDurationToZero() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(-3); // clock rewinds after Start, no pause @@ -377,7 +296,7 @@ public void End_ClockRewindsWhilePaused_DoesNotInflateDuration() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(10); @@ -399,7 +318,7 @@ public void End_AfterShortPause_ReportsDurationMinusPause() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(4); @@ -424,7 +343,7 @@ public void End_WhilePaused_ExcludesInFlightPauseFromDuration() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(5); @@ -452,7 +371,7 @@ public void End_AfterExtendedPauseRollover_ReportsPrePauseDuration() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(10); // 10 s engaged before pause @@ -474,7 +393,7 @@ public void Heartbeat_AfterShortPause_ReportsPauseAdjustedDuration() var now = new DateTime(2026, 4, 20, 12, 0, 0, DateTimeKind.Utc); DateTime Clock() => now; - using var session = new Session(MockTrack, performanceSnapshot: null, getUtcNow: Clock); + using var session = new Session(MockTrack, getUtcNow: Clock); session.Start(); now = now.AddSeconds(4); @@ -645,43 +564,6 @@ void ThrowingTrack(string name, Dictionary props) finally { Log.Writer = prevWriter; } } - [Test] - public void OnHeartbeat_PerformanceSnapshotThrows_ShipsHeartbeatWithoutPerfFields() - { - // PerformanceSnapshotProvider is studio-supplied and crosses an - // API boundary. A throwing provider must not prevent the - // heartbeat from shipping — the SafePerformanceSnapshot wrapper - // returns null on exception so the heartbeat ships with the - // core fields only. - var warnings = new List(); - var prevWriter = Log.Writer; - Log.Writer = line => { lock (warnings) warnings.Add(line); }; - try - { - Func> snapshot = () => - throw new InvalidOperationException("perf explode"); - - using var session = new Session(MockTrack, snapshot); - session.Start(); - - Assert.DoesNotThrow(() => session.OnHeartbeat(), - "a throwing performance snapshot must not escape Session"); - - var beat = _events.Last(e => e.name == "session_heartbeat"); - CollectionAssert.AreEquivalent( - new[] { "sessionId", "durationSec" }, - beat.props.Keys, - "heartbeat should carry only the core fields when the snapshot throws"); - - lock (warnings) - { - Assert.IsTrue(warnings.Any(w => w.Contains("performance snapshot threw")), - "SafePerformanceSnapshot must log a warning when the provider throws"); - } - } - finally { Log.Writer = prevWriter; } - } - [Test] public void Start_TrackCallbackThrows_DoesNotEscape() { diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 8222e5714..77f289a96 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -27,6 +27,7 @@ public void TearDown() { ImmutableAudience.ResetState(); ImmutableAudience.LaunchContextProvider = null; + ImmutableAudience.ContextProvider = null; ImmutableAudience.DefaultPersistentDataPathProvider = null; Identity.Reset(_testDir); if (Directory.Exists(_testDir)) @@ -58,6 +59,96 @@ protected override Task SendAsync(HttpRequestMessage reques } } + // ----------------------------------------------------------------- + // Unity context provider + // ----------------------------------------------------------------- + + [Test] + public void ContextProvider_Set_MergesFieldsIntoEveryMessageContext() + { + ImmutableAudience.ContextProvider = () => new Dictionary + { + ["userAgent"] = "TestOS 1.0", + ["locale"] = "en-GB", + ["timezone"] = "Europe/London", + ["screen"] = "1920x1080", + }; + + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Track("unit_test_event"); + ImmutableAudience.Shutdown(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + + Assert.IsTrue(blobs.Any(b => + b.Contains("\"userAgent\":\"TestOS 1.0\"") && + b.Contains("\"locale\":\"en-GB\"") && + b.Contains("\"timezone\":\"Europe/London\"") && + b.Contains("\"screen\":\"1920x1080\"") && + b.Contains("\"library\":")), + "Enqueue should merge ContextProvider fields into msg.context alongside library/libraryVersion"); + } + + [Test] + public void ContextProvider_Set_MergesOnIdentifyPath() + { + // EnqueueIdentity must merge ContextProvider fields the same way + // EnqueueTrack does — otherwise Identify events ship without the + // userAgent / locale / timezone / screen context every other + // event carries. + ImmutableAudience.ContextProvider = () => new Dictionary + { + ["userAgent"] = "TestOS 1.0", + ["locale"] = "en-GB", + }; + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Identify("player-42", IdentityType.Custom); + ImmutableAudience.Shutdown(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + + Assert.IsTrue(blobs.Any(b => + b.Contains("\"type\":\"identify\"") && + b.Contains("\"userAgent\":\"TestOS 1.0\"") && + b.Contains("\"locale\":\"en-GB\"")), + "Identify message must carry ContextProvider fields in msg.context"); + } + + [Test] + public void ContextProvider_ThrowingDelegate_SwallowsAndShipsBaseContext() + { + ImmutableAudience.ContextProvider = () => throw new InvalidOperationException("boom"); + + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Track("unit_test_event"); + ImmutableAudience.Shutdown(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + + Assert.IsTrue(blobs.Any(b => b.Contains("\"unit_test_event\"") && b.Contains("\"library\":")), + "event should still ship with base context when ContextProvider throws"); + } + + [Test] + public void ContextProvider_ReturnsNull_ShipsBaseContext() + { + ImmutableAudience.ContextProvider = () => null; + + ImmutableAudience.Init(MakeConfig()); + ImmutableAudience.Track("unit_test_event"); + ImmutableAudience.Shutdown(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + + Assert.IsTrue(blobs.Any(b => b.Contains("\"unit_test_event\"") && b.Contains("\"library\":")), + "event should still ship with base context when ContextProvider returns null"); + } + // ----------------------------------------------------------------- // Init // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/link.xml b/src/Packages/Audience/link.xml index 456c9d2a3..5f9428bb1 100644 --- a/src/Packages/Audience/link.xml +++ b/src/Packages/Audience/link.xml @@ -12,6 +12,7 @@ framework dependency. --> + From e03d3c9b4e0f276468432f3c28d51ac3a392a44a Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 17:06:25 +1000 Subject: [PATCH 2/3] fix(audience-tests): replace Assert.DoesNotThrowAsync with await for Unity NUnit compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unity's com.unity.test-framework ships NUnit 3.5, which predates Assert.DoesNotThrowAsync. Awaiting the task achieves the same assertion semantic — NUnit fails the test if the awaited task throws. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Tests/Runtime/Transport/HttpTransportTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index a848679cf..8d901bd24 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -460,7 +460,7 @@ public async Task SendBatchAsync_ErrorCallbackThrows_DoesNotCrash() onError: _ => throw new InvalidOperationException("callback bug"), handler: handler); - Assert.DoesNotThrowAsync(() => transport.SendBatchAsync()); + await transport.SendBatchAsync(); } #if IMMUTABLE_AUDIENCE_GZIP From 62fcd03c745b510053fe7c49a45e28e04ff1a9b5 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 25 Apr 2026 13:00:49 +1000 Subject: [PATCH 3/3] feat(audience): expose SDK state and add BaseUrl override Adds six public diagnostic getters on ImmutableAudience: - Initialized: true between Init and Shutdown. - CurrentConsent: live consent level. - UserId: last Identify value; null below Full consent. - AnonymousId: anonymous, persistent ID. - SessionId: current session id; rotates on Init / Reset / timeout. - QueueSize: number of unsent events (memory + disk). Each getter is safe from any thread and returns a safe default when the SDK cannot answer. EventQueue gains an internal InMemoryCount property so QueueSize can sum without locking. Adds optional AudienceConfig.BaseUrl override (matches Web/Pixel SDK pattern; addresses #709 review). Null preserves the key-prefix derivation; integrations needing a different backend pass the URL directly. Tests cover diagnostics lifecycle and BaseUrl resolution. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/AudienceConfig.cs | 5 + .../Audience/Runtime/Core/Constants.cs | 18 ++- .../Audience/Runtime/ImmutableAudience.cs | 58 ++++++++- .../Audience/Runtime/Transport/EventQueue.cs | 6 + .../Runtime/Transport/HttpTransport.cs | 4 +- .../Audience/Tests/Runtime/ConstantsTests.cs | 48 ++++++++ .../Tests/Runtime/ImmutableAudienceTests.cs | 111 ++++++++++++++++++ .../Runtime/Transport/HttpTransportTests.cs | 19 +++ 8 files changed, 258 insertions(+), 11 deletions(-) diff --git a/src/Packages/Audience/Runtime/AudienceConfig.cs b/src/Packages/Audience/Runtime/AudienceConfig.cs index c4b2ae9af..49cb55fd9 100644 --- a/src/Packages/Audience/Runtime/AudienceConfig.cs +++ b/src/Packages/Audience/Runtime/AudienceConfig.cs @@ -10,6 +10,11 @@ public class AudienceConfig // Studio API key. Required — Init throws if null. public string? PublishableKey { get; set; } + // Override the default API base URL. When null, keys starting with + // "pk_imapik-test-" resolve to Sandbox and all other keys resolve + // to Production. Set explicitly to target a different backend. + public string? BaseUrl { get; set; } + // Initial consent level. public ConsentLevel Consent { get; set; } = ConsentLevel.None; diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index f57a8f3bd..f567aef8a 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -26,14 +26,22 @@ internal static class Constants internal const string PublishableKeyHeader = "x-immutable-publishable-key"; - internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath; - internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath; - internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath; + internal static string MessagesUrl(string? publishableKey, string? baseUrlOverride = null) => + BaseUrl(publishableKey, baseUrlOverride) + MessagesPath; + internal static string ConsentUrl(string? publishableKey, string? baseUrlOverride = null) => + BaseUrl(publishableKey, baseUrlOverride) + ConsentPath; + internal static string DataUrl(string? publishableKey, string? baseUrlOverride = null) => + BaseUrl(publishableKey, baseUrlOverride) + DataPath; - internal static string BaseUrl(string? publishableKey) => - publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) + // Override wins when non-empty; otherwise test keys map to Sandbox + // and every other key maps to Production. Matches @imtbl/audience. + internal static string BaseUrl(string? publishableKey, string? baseUrlOverride = null) + { + if (!string.IsNullOrEmpty(baseUrlOverride)) return baseUrlOverride!; + return publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) ? SandboxBaseUrl : ProductionBaseUrl; + } } // Message type values written to (and read back from) the "type" field. diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 45d84239b..d1d9a845b 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -51,6 +51,56 @@ public static class ImmutableAudience // assignments from SetConsent without taking _initLock. private static volatile Session? _session; + // True between Init() and Shutdown(). + public static bool Initialized => _initialized; + + // The consent level the SDK is currently honouring. + public static ConsentLevel CurrentConsent => _state.Level; + + // The user ID from the most recent Identify() call. Null after + // Reset() or when consent is below Full. + public static string? UserId => _state.UserId; + + // An anonymous, persistent ID — unlike SessionId (rotates per + // session) and UserId (identifies the user). Reset() and + // SetConsent(None) wipe it; null while consent is None. + 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!); + } + } + + // The current session's ID. A new ID is assigned at Init(), at Reset(), + // and when the app resumes after the previous session has timed out. + // Null while consent is None. + public static string? SessionId => _session?.SessionId; + + // Number of unsent events (in memory and on disk). + 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) { @@ -82,7 +132,7 @@ public static void Init(AudienceConfig config) _store = new DiskStore(config.PersistentDataPath); _queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize); - _transport = new HttpTransport(_store, config.PublishableKey, config.OnError, config.HttpHandler); + _transport = new HttpTransport(_store, config.PublishableKey, config.BaseUrl, config.OnError, config.HttpHandler); _controlClient = config.HttpHandler != null ? new HttpClient(config.HttpHandler, disposeHandler: false) : new HttpClient(); @@ -338,7 +388,7 @@ public static Task DeleteData(string? userId = null) query = "anonymousId=" + Uri.EscapeDataString(anonymousId); } - var url = Constants.DataUrl(config.PublishableKey) + "?" + query; + var url = Constants.DataUrl(config.PublishableKey, config.BaseUrl) + "?" + query; var onError = config.OnError; var publishableKey = config.PublishableKey; var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None; @@ -500,7 +550,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev var client = _controlClient; if (client == null) return; - var url = Constants.ConsentUrl(config.PublishableKey); + var url = Constants.ConsentUrl(config.PublishableKey, config.BaseUrl); var publishableKey = config.PublishableKey; var onError = config.OnError; var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None; @@ -719,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/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 80c2dc9ed..4b422d342 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -28,18 +28,20 @@ internal sealed class HttpTransport : IDisposable // store: source of event batches. // publishableKey: sent as x-immutable-publishable-key on every request. + // baseUrlOverride: explicit backend URL. Null = derive from publishableKey prefix. // onError: optional failure callback. Exceptions thrown inside it are caught. // handler / getUtcNow: test seams; null for production use. internal HttpTransport( DiskStore store, string publishableKey, + string? baseUrlOverride = null, Action? onError = null, HttpMessageHandler? handler = null, Func? getUtcNow = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey)); - _url = Constants.MessagesUrl(publishableKey); + _url = Constants.MessagesUrl(publishableKey, baseUrlOverride); _onError = onError; // disposeHandler: false so the consumer can reuse their handler // across Init/Shutdown cycles (matches _controlClient's policy). diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs index f72851810..03d641382 100644 --- a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs @@ -6,6 +6,54 @@ namespace Immutable.Audience.Tests [TestFixture] internal class ConstantsTests { + // ----------------------------------------------------------------- + // BaseUrl resolution + // ----------------------------------------------------------------- + + [Test] + public void BaseUrl_TestKey_ResolvesToSandbox() + { + Assert.AreEqual(Constants.SandboxBaseUrl, + Constants.BaseUrl("pk_imapik-test-abc")); + } + + [Test] + public void BaseUrl_NonTestKey_ResolvesToProduction() + { + Assert.AreEqual(Constants.ProductionBaseUrl, + Constants.BaseUrl("pk_imapik-prod-abc")); + } + + [Test] + public void BaseUrl_NullKey_ResolvesToProduction() + { + Assert.AreEqual(Constants.ProductionBaseUrl, + Constants.BaseUrl(null)); + } + + [Test] + public void BaseUrl_Override_WinsOverKeyPrefix() + { + // Override wins even for a test-prefixed key that would + // otherwise derive to Sandbox. + const string custom = "https://api.dev.immutable.com"; + Assert.AreEqual(custom, + Constants.BaseUrl("pk_imapik-test-abc", custom)); + } + + [Test] + public void BaseUrl_EmptyOverride_FallsBackToKeyDerivation() + { + // Empty-string override is treated as "no override" so the + // key-prefix fallback still kicks in. + Assert.AreEqual(Constants.SandboxBaseUrl, + Constants.BaseUrl("pk_imapik-test-abc", "")); + } + + // ----------------------------------------------------------------- + // Library version + // ----------------------------------------------------------------- + [Test] public void LibraryVersion_MatchesPackageJson() { diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 77f289a96..60b2e4cb2 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -59,6 +59,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 // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index 8d901bd24..0ecd9d95d 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -152,6 +152,25 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey() StringAssert.StartsWith(Constants.ProductionBaseUrl, captured.RequestUri.ToString()); } + [Test] + public async Task SendBatchAsync_BaseUrlOverride_WinsOverKeyPrefix() + { + _store.Write("{\"type\":\"track\"}"); + + HttpRequestMessage captured = null; + var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + onRequest: req => captured = req); + const string custom = "https://api.dev.immutable.com"; + // Test-prefixed key would resolve to Sandbox on its own; the + // explicit override must win. + using var transport = new HttpTransport(_store, "pk_imapik-test-key1", + baseUrlOverride: custom, handler: handler); + + await transport.SendBatchAsync(); + + StringAssert.StartsWith(custom, captured.RequestUri.ToString()); + } + [Test] public async Task SendBatchAsync_EmptyQueue_ReturnsFalse() {