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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
21 changes: 21 additions & 0 deletions src/Packages/Audience/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project>
<!--
Redirect dotnet build outputs to a repo-root artifacts/ folder so they
don't leak into the Unity package directory. When the package is
referenced from a Unity project via a `file:` path, Unity scans every
folder under the package root. Finding bin/obj/*.dll trips
"Multiple precompiled assemblies" errors and the locale-specific
resource DLLs also confuse Unity's asset importer.

Targets:
artifacts/Audience.Runtime/bin|obj/
artifacts/Audience.Tests/bin|obj/

$(MSBuildThisFileDirectory) here = src/Packages/Audience/
../../../artifacts/ = <repo-root>/artifacts/
-->
<PropertyGroup>
<BaseOutputPath>$(MSBuildThisFileDirectory)../../../artifacts/$(MSBuildProjectName)/bin/</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)../../../artifacts/$(MSBuildProjectName)/obj/</BaseIntermediateOutputPath>
</PropertyGroup>
</Project>
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
35 changes: 2 additions & 33 deletions src/Packages/Audience/Runtime/Core/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ internal sealed class Session : IDisposable
internal const int PauseTimeoutMs = 30_000;

private readonly TrackDelegate _track;
private readonly Func<Dictionary<string, object>>? _performanceSnapshot;
private readonly Func<DateTime> _getUtcNow;
private readonly int _heartbeatIntervalMs;
private readonly object _lock = new object();
Expand All @@ -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<Dictionary<string, object>>? performanceSnapshot = null,
Func<DateTime>? getUtcNow = null,
int heartbeatIntervalMs = HeartbeatIntervalMs)
{
_track = track ?? throw new ArgumentNullException(nameof(track));
_performanceSnapshot = performanceSnapshot;
_getUtcNow = getUtcNow ?? (() => DateTime.UtcNow);
_heartbeatIntervalMs = heartbeatIntervalMs;
}
Expand Down Expand Up @@ -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<string, object>
{
["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);
}

Expand All @@ -289,22 +274,6 @@ private void SafeTrack(string eventName, Dictionary<string, object> properties)
}
}

// Stops exceptions from the studio-supplied snapshot callback from
// reaching the background timer.
private Dictionary<string, object>? 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()
Expand Down
109 changes: 94 additions & 15 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? DefaultPersistentDataPathProvider;
internal static Func<Dictionary<string, object>>? 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<string>? DefaultPersistentDataPathProvider;
internal static volatile Func<IReadOnlyDictionary<string, object>>? LaunchContextProvider;
internal static volatile Func<IReadOnlyDictionary<string, object>>? 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)
{
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -743,6 +790,7 @@ internal static void ResetState()
// Anonymous the userId is stripped.
private static void EnqueueTrack(Dictionary<string, object>? msg)
{
MergeUnityContext(msg);
_queue?.EnqueueChecked(msg, m =>
{
var state = _state;
Expand All @@ -756,10 +804,41 @@ private static void EnqueueTrack(Dictionary<string, object>? msg)
// Identify / Alias require Full; drop if consent has downgraded.
private static void EnqueueIdentity(Dictionary<string, object>? msg)
{
MergeUnityContext(msg);
_queue?.EnqueueChecked(msg, m =>
_state.Level == ConsentLevel.Full ? m : null);
}

private static void MergeUnityContext(Dictionary<string, object>? msg)
{
if (msg == null) return;

var provider = ContextProvider;
if (provider == null) return;

IReadOnlyDictionary<string, object>? 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<string, object> ctx))
{
ctx = new Dictionary<string, object>();
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
Expand Down Expand Up @@ -825,7 +904,7 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt
var provider = LaunchContextProvider;
if (provider != null)
{
Dictionary<string, object>? unityContext = null;
IReadOnlyDictionary<string, object>? unityContext = null;
try { unityContext = provider(); }
catch (Exception ex)
{
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
Loading
Loading