Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Packages/Audience/Runtime/AudienceConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
18 changes: 13 additions & 5 deletions src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 53 additions & 5 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
ImmutableJeffrey marked this conversation as resolved.

// 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)
{
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/Packages/Audience/Runtime/Transport/EventQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Packages/Audience/Runtime/Transport/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudienceError>? onError = null,
HttpMessageHandler? handler = null,
Func<DateTime>? 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).
Expand Down
48 changes: 48 additions & 0 deletions src/Packages/Audience/Tests/Runtime/ConstantsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
111 changes: 111 additions & 0 deletions src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,117 @@ protected override Task<HttpResponseMessage> 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
// -----------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading