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/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/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..d1d9a845b 100644
--- a/src/Packages/Audience/Runtime/ImmutableAudience.cs
+++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs
@@ -38,18 +38,69 @@ 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
// 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)
{
@@ -81,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();
@@ -337,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;
@@ -499,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;
@@ -701,9 +752,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
@@ -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.
@@ -743,6 +790,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 +804,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 +904,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/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/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..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()
{
@@ -23,11 +71,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..60b2e4cb2 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,207 @@ 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
+ // -----------------------------------------------------------------
+
+ [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/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs
index a848679cf..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()
{
@@ -460,7 +479,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
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.
-->
+