Skip to content

Commit 62fcd03

Browse files
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) <noreply@anthropic.com>
1 parent e03d3c9 commit 62fcd03

8 files changed

Lines changed: 258 additions & 11 deletions

File tree

src/Packages/Audience/Runtime/AudienceConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public class AudienceConfig
1010
// Studio API key. Required — Init throws if null.
1111
public string? PublishableKey { get; set; }
1212

13+
// Override the default API base URL. When null, keys starting with
14+
// "pk_imapik-test-" resolve to Sandbox and all other keys resolve
15+
// to Production. Set explicitly to target a different backend.
16+
public string? BaseUrl { get; set; }
17+
1318
// Initial consent level.
1419
public ConsentLevel Consent { get; set; } = ConsentLevel.None;
1520

src/Packages/Audience/Runtime/Core/Constants.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,22 @@ internal static class Constants
2626

2727
internal const string PublishableKeyHeader = "x-immutable-publishable-key";
2828

29-
internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath;
30-
internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath;
31-
internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath;
29+
internal static string MessagesUrl(string? publishableKey, string? baseUrlOverride = null) =>
30+
BaseUrl(publishableKey, baseUrlOverride) + MessagesPath;
31+
internal static string ConsentUrl(string? publishableKey, string? baseUrlOverride = null) =>
32+
BaseUrl(publishableKey, baseUrlOverride) + ConsentPath;
33+
internal static string DataUrl(string? publishableKey, string? baseUrlOverride = null) =>
34+
BaseUrl(publishableKey, baseUrlOverride) + DataPath;
3235

33-
internal static string BaseUrl(string? publishableKey) =>
34-
publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
36+
// Override wins when non-empty; otherwise test keys map to Sandbox
37+
// and every other key maps to Production. Matches @imtbl/audience.
38+
internal static string BaseUrl(string? publishableKey, string? baseUrlOverride = null)
39+
{
40+
if (!string.IsNullOrEmpty(baseUrlOverride)) return baseUrlOverride!;
41+
return publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
3542
? SandboxBaseUrl
3643
: ProductionBaseUrl;
44+
}
3745
}
3846

3947
// Message type values written to (and read back from) the "type" field.

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,56 @@ public static class ImmutableAudience
5151
// assignments from SetConsent without taking _initLock.
5252
private static volatile Session? _session;
5353

54+
// True between Init() and Shutdown().
55+
public static bool Initialized => _initialized;
56+
57+
// The consent level the SDK is currently honouring.
58+
public static ConsentLevel CurrentConsent => _state.Level;
59+
60+
// The user ID from the most recent Identify() call. Null after
61+
// Reset() or when consent is below Full.
62+
public static string? UserId => _state.UserId;
63+
64+
// An anonymous, persistent ID — unlike SessionId (rotates per
65+
// session) and UserId (identifies the user). Reset() and
66+
// SetConsent(None) wipe it; null while consent is None.
67+
public static string? AnonymousId
68+
{
69+
get
70+
{
71+
if (!_initialized) return null;
72+
var config = _config;
73+
if (config == null || !_state.Level.CanTrack()) return null;
74+
// PersistentDataPath is validated non-null in Init; compiler can't propagate that.
75+
return Identity.Get(config.PersistentDataPath!);
76+
}
77+
}
78+
79+
// The current session's ID. A new ID is assigned at Init(), at Reset(),
80+
// and when the app resumes after the previous session has timed out.
81+
// Null while consent is None.
82+
public static string? SessionId => _session?.SessionId;
83+
84+
// Number of unsent events (in memory and on disk).
85+
public static int QueueSize
86+
{
87+
get
88+
{
89+
// Fence off the volatile _initialized load first, matching
90+
// the protocol documented on the reference fields. Without
91+
// this, a weak-memory-order reader could observe
92+
// _initialized=true but _queue/_store still null — the ?.
93+
// short-circuits to 0 in that case, but the inconsistency
94+
// would break the protocol the file claims to follow.
95+
if (!_initialized) return 0;
96+
var queue = _queue;
97+
var store = _store;
98+
var memory = queue?.InMemoryCount ?? 0;
99+
var disk = store?.Count() ?? 0;
100+
return memory + disk;
101+
}
102+
}
103+
54104
// Starts the SDK. Call once at launch.
55105
public static void Init(AudienceConfig config)
56106
{
@@ -82,7 +132,7 @@ public static void Init(AudienceConfig config)
82132

83133
_store = new DiskStore(config.PersistentDataPath);
84134
_queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize);
85-
_transport = new HttpTransport(_store, config.PublishableKey, config.OnError, config.HttpHandler);
135+
_transport = new HttpTransport(_store, config.PublishableKey, config.BaseUrl, config.OnError, config.HttpHandler);
86136
_controlClient = config.HttpHandler != null
87137
? new HttpClient(config.HttpHandler, disposeHandler: false)
88138
: new HttpClient();
@@ -338,7 +388,7 @@ public static Task DeleteData(string? userId = null)
338388
query = "anonymousId=" + Uri.EscapeDataString(anonymousId);
339389
}
340390

341-
var url = Constants.DataUrl(config.PublishableKey) + "?" + query;
391+
var url = Constants.DataUrl(config.PublishableKey, config.BaseUrl) + "?" + query;
342392
var onError = config.OnError;
343393
var publishableKey = config.PublishableKey;
344394
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
@@ -500,7 +550,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev
500550
var client = _controlClient;
501551
if (client == null) return;
502552

503-
var url = Constants.ConsentUrl(config.PublishableKey);
553+
var url = Constants.ConsentUrl(config.PublishableKey, config.BaseUrl);
504554
var publishableKey = config.PublishableKey;
505555
var onError = config.OnError;
506556
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
@@ -719,8 +769,6 @@ internal static void ResetState()
719769
}
720770
}
721771

722-
internal static ConsentLevel CurrentConsent => _state.Level;
723-
724772
internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync();
725773

726774
// Drives SendBatch without a real timer so the overlapping-tick guard is testable.

src/Packages/Audience/Runtime/Transport/EventQueue.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize)
4949
_drainThread.Start();
5050
}
5151

52+
// Approximate count of events currently in the in-memory queue
53+
// awaiting drain to disk. Lock-free read on ConcurrentQueue.Count
54+
// — a snapshot that can race with concurrent enqueue / dequeue.
55+
// Good enough for status-panel display; not an invariant.
56+
internal int InMemoryCount => _memory.Count;
57+
5258
// Enqueues a message dictionary. Lock-free; safe from any thread.
5359
// The dictionary is not copied -- callers must not mutate it after
5460
// enqueue. Serialisation happens on the drain thread so Track() stays

src/Packages/Audience/Runtime/Transport/HttpTransport.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@ internal sealed class HttpTransport : IDisposable
2828

2929
// store: source of event batches.
3030
// publishableKey: sent as x-immutable-publishable-key on every request.
31+
// baseUrlOverride: explicit backend URL. Null = derive from publishableKey prefix.
3132
// onError: optional failure callback. Exceptions thrown inside it are caught.
3233
// handler / getUtcNow: test seams; null for production use.
3334
internal HttpTransport(
3435
DiskStore store,
3536
string publishableKey,
37+
string? baseUrlOverride = null,
3638
Action<AudienceError>? onError = null,
3739
HttpMessageHandler? handler = null,
3840
Func<DateTime>? getUtcNow = null)
3941
{
4042
_store = store ?? throw new ArgumentNullException(nameof(store));
4143
_publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey));
42-
_url = Constants.MessagesUrl(publishableKey);
44+
_url = Constants.MessagesUrl(publishableKey, baseUrlOverride);
4345
_onError = onError;
4446
// disposeHandler: false so the consumer can reuse their handler
4547
// across Init/Shutdown cycles (matches _controlClient's policy).

src/Packages/Audience/Tests/Runtime/ConstantsTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,54 @@ namespace Immutable.Audience.Tests
66
[TestFixture]
77
internal class ConstantsTests
88
{
9+
// -----------------------------------------------------------------
10+
// BaseUrl resolution
11+
// -----------------------------------------------------------------
12+
13+
[Test]
14+
public void BaseUrl_TestKey_ResolvesToSandbox()
15+
{
16+
Assert.AreEqual(Constants.SandboxBaseUrl,
17+
Constants.BaseUrl("pk_imapik-test-abc"));
18+
}
19+
20+
[Test]
21+
public void BaseUrl_NonTestKey_ResolvesToProduction()
22+
{
23+
Assert.AreEqual(Constants.ProductionBaseUrl,
24+
Constants.BaseUrl("pk_imapik-prod-abc"));
25+
}
26+
27+
[Test]
28+
public void BaseUrl_NullKey_ResolvesToProduction()
29+
{
30+
Assert.AreEqual(Constants.ProductionBaseUrl,
31+
Constants.BaseUrl(null));
32+
}
33+
34+
[Test]
35+
public void BaseUrl_Override_WinsOverKeyPrefix()
36+
{
37+
// Override wins even for a test-prefixed key that would
38+
// otherwise derive to Sandbox.
39+
const string custom = "https://api.dev.immutable.com";
40+
Assert.AreEqual(custom,
41+
Constants.BaseUrl("pk_imapik-test-abc", custom));
42+
}
43+
44+
[Test]
45+
public void BaseUrl_EmptyOverride_FallsBackToKeyDerivation()
46+
{
47+
// Empty-string override is treated as "no override" so the
48+
// key-prefix fallback still kicks in.
49+
Assert.AreEqual(Constants.SandboxBaseUrl,
50+
Constants.BaseUrl("pk_imapik-test-abc", ""));
51+
}
52+
53+
// -----------------------------------------------------------------
54+
// Library version
55+
// -----------------------------------------------------------------
56+
957
[Test]
1058
public void LibraryVersion_MatchesPackageJson()
1159
{

src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,117 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
5959
}
6060
}
6161

62+
// -----------------------------------------------------------------
63+
// Diagnostic getters (Initialized / CurrentConsent / UserId /
64+
// AnonymousId / SessionId / QueueSize)
65+
// -----------------------------------------------------------------
66+
67+
[Test]
68+
public void Initialized_FlipsAroundInitAndShutdown()
69+
{
70+
Assert.IsFalse(ImmutableAudience.Initialized,
71+
"Initialized should be false before Init");
72+
73+
ImmutableAudience.Init(MakeConfig());
74+
Assert.IsTrue(ImmutableAudience.Initialized,
75+
"Initialized should flip true after Init");
76+
77+
ImmutableAudience.Shutdown();
78+
Assert.IsFalse(ImmutableAudience.Initialized,
79+
"Initialized should flip back to false after Shutdown");
80+
}
81+
82+
[Test]
83+
public void CurrentConsent_ReflectsLatestSetConsent()
84+
{
85+
ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous));
86+
Assert.AreEqual(ConsentLevel.Anonymous, ImmutableAudience.CurrentConsent);
87+
88+
ImmutableAudience.SetConsent(ConsentLevel.Full);
89+
Assert.AreEqual(ConsentLevel.Full, ImmutableAudience.CurrentConsent);
90+
91+
ImmutableAudience.SetConsent(ConsentLevel.None);
92+
Assert.AreEqual(ConsentLevel.None, ImmutableAudience.CurrentConsent);
93+
}
94+
95+
[Test]
96+
public void UserId_Uninitialised_ReturnsNull()
97+
{
98+
Assert.IsNull(ImmutableAudience.UserId);
99+
}
100+
101+
[Test]
102+
public void UserId_AfterIdentifyAndReset_TracksState()
103+
{
104+
ImmutableAudience.Init(MakeConfig(ConsentLevel.Full));
105+
Assert.IsNull(ImmutableAudience.UserId,
106+
"UserId should be null until Identify is called");
107+
108+
ImmutableAudience.Identify("player-42", IdentityType.Custom);
109+
Assert.AreEqual("player-42", ImmutableAudience.UserId,
110+
"UserId must reflect the most recent Identify call");
111+
112+
ImmutableAudience.Reset();
113+
Assert.IsNull(ImmutableAudience.UserId,
114+
"Reset must clear UserId so the next player is not attributed to the previous one");
115+
}
116+
117+
[Test]
118+
public void AnonymousId_ConsentNone_ReturnsNull()
119+
{
120+
// Anonymous identifier is consent-gated: below tracking consent,
121+
// no stable id should leak through the getter.
122+
ImmutableAudience.Init(MakeConfig(ConsentLevel.None));
123+
124+
Assert.IsNull(ImmutableAudience.AnonymousId);
125+
}
126+
127+
[Test]
128+
public void AnonymousId_ConsentAnonymous_ReturnsPersistedId()
129+
{
130+
ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous));
131+
// Track once so Identity.GetOrCreate runs and writes the id file.
132+
ImmutableAudience.Track("warmup_event");
133+
134+
var id = ImmutableAudience.AnonymousId;
135+
Assert.IsFalse(string.IsNullOrEmpty(id),
136+
"AnonymousId should return the persisted id once tracking has created one");
137+
}
138+
139+
[Test]
140+
public void SessionId_MirrorsSessionLifecycle()
141+
{
142+
Assert.IsNull(ImmutableAudience.SessionId,
143+
"SessionId should be null before Init");
144+
145+
ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous));
146+
Assert.IsFalse(string.IsNullOrEmpty(ImmutableAudience.SessionId),
147+
"SessionId should be non-null once Init creates a session");
148+
149+
ImmutableAudience.Shutdown();
150+
Assert.IsNull(ImmutableAudience.SessionId,
151+
"SessionId should be null after Shutdown disposes the session");
152+
}
153+
154+
[Test]
155+
public void QueueSize_ZeroBeforeInit_GrowsWithEnqueue()
156+
{
157+
Assert.AreEqual(0, ImmutableAudience.QueueSize,
158+
"QueueSize should be 0 before Init");
159+
160+
ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous));
161+
// Init enqueues session_start + game_launch; those stay
162+
// in-memory until a flush. QueueSize sums memory + disk so the
163+
// pre-flush snapshot must be > 0.
164+
var afterInit = ImmutableAudience.QueueSize;
165+
Assert.Greater(afterInit, 0,
166+
"QueueSize should include session_start and game_launch after Init");
167+
168+
ImmutableAudience.Track("explicit_track_event");
169+
Assert.Greater(ImmutableAudience.QueueSize, afterInit,
170+
"QueueSize should grow when a new event is enqueued");
171+
}
172+
62173
// -----------------------------------------------------------------
63174
// Unity context provider
64175
// -----------------------------------------------------------------

src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,25 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey()
152152
StringAssert.StartsWith(Constants.ProductionBaseUrl, captured.RequestUri.ToString());
153153
}
154154

155+
[Test]
156+
public async Task SendBatchAsync_BaseUrlOverride_WinsOverKeyPrefix()
157+
{
158+
_store.Write("{\"type\":\"track\"}");
159+
160+
HttpRequestMessage captured = null;
161+
var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}",
162+
onRequest: req => captured = req);
163+
const string custom = "https://api.dev.immutable.com";
164+
// Test-prefixed key would resolve to Sandbox on its own; the
165+
// explicit override must win.
166+
using var transport = new HttpTransport(_store, "pk_imapik-test-key1",
167+
baseUrlOverride: custom, handler: handler);
168+
169+
await transport.SendBatchAsync();
170+
171+
StringAssert.StartsWith(custom, captured.RequestUri.ToString());
172+
}
173+
155174
[Test]
156175
public async Task SendBatchAsync_EmptyQueue_ReturnsFalse()
157176
{

0 commit comments

Comments
 (0)