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
20 changes: 20 additions & 0 deletions src/Packages/Audience/Runtime/Core/ConsentState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#nullable enable

namespace System.Runtime.CompilerServices
{
// Unity's .NET runtime doesn't include this type, but the C# compiler
// needs it to exist to build the ConsentState record below. Declaring
// an empty one here gives the compiler what it looks for.
internal static class IsExternalInit { }
}

namespace Immutable.Audience
{
// Pairs the consent level with the user id so the two always move
// together. Updates swap the whole pair at once — a reader never sees
// the new consent level alongside a leftover user id.
internal sealed record ConsentState(ConsentLevel Level, string? UserId)
{
internal static readonly ConsentState None = new(ConsentLevel.None, null);
}
}
141 changes: 85 additions & 56 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ public static class ImmutableAudience
{
// Reference fields are written inside _initLock; readers check the
// `volatile _initialized` flag first so they never see a half-initialised state.
// _consent and _session are written only inside _initLock but read outside,
// so they stay `volatile` to make writes visible across threads.
// _userId is written outside the lock (Identify) — `volatile` for the same reason.
// _state (consent level + userId) and _session are volatile so a write
// on one thread is visible on any other. Every _state write happens
// under _initLock so level and userId always move together — callers
// never observe (Anonymous, oldUserId).
//
// Init / Shutdown / Reset / SetConsent hold _initLock only to flip state
// and capture references; they release the lock before running blocking
Expand All @@ -30,8 +31,7 @@ public static class ImmutableAudience
private static HttpClient? _controlClient;
private static CancellationTokenSource? _shutdownCancellationSource;
private static Timer? _sendTimer;
private static volatile ConsentLevel _consent;
private static volatile string? _userId;
private static volatile ConsentState _state = ConsentState.None;
private static volatile bool _initialized;
private static readonly object _initLock = new object();

Expand Down Expand Up @@ -76,7 +76,8 @@ public static void Init(AudienceConfig config)
_config = config;
Log.Enabled = config.Debug;
// Persisted consent overrides the config default (prior downgrade survives restart).
_consent = ConsentStore.Load(config.PersistentDataPath) ?? config.Consent;
var initialLevel = ConsentStore.Load(config.PersistentDataPath) ?? config.Consent;
_state = new ConsentState(initialLevel, null);

_store = new DiskStore(config.PersistentDataPath);
_queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize);
Expand All @@ -94,11 +95,11 @@ public static void Init(AudienceConfig config)
_initialized = true;

// Snapshot so a racing SetConsent(None) can't drop the launch event.
consentAtInit = _consent;
consentAtInit = initialLevel;

// Session created under the lock; Start() deferred until after
// release because session_start → Track takes its own locks.
if (consentAtInit.CanTrack())
if (initialLevel.CanTrack())
_session = new Session(Track);

// Captured reference: a later SetConsent(None) may dispose this
Expand Down Expand Up @@ -136,7 +137,8 @@ internal static void OnResume()
// IEvent implementations validate required fields at compile time.
public static void Track(IEvent evt)
{
if (!CanTrack()) return;
var state = _state;
if (!_initialized || !state.Level.CanTrack()) return;
if (evt == null)
{
Log.Warn("Track(IEvent) called with null event — dropping.");
Expand Down Expand Up @@ -166,18 +168,20 @@ public static void Track(IEvent evt)
return;
}

var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, _consent);
var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level);
// ToProperties returns a fresh dict per call, so no snapshot needed.
var msg = MessageBuilder.Track(eventName, anonymousId, _userId, config.PackageVersion, properties);
Enqueue(msg);
var userId = state.Level == ConsentLevel.Full ? state.UserId : null;
var msg = MessageBuilder.Track(eventName, anonymousId, userId, config.PackageVersion, properties);
EnqueueTrack(msg);
}

// Sends a custom event. For predefined names (purchase, progression,
// resource, milestone_reached), prefer the typed overload which
// validates required fields.
public static void Track(string eventName, Dictionary<string, object>? properties = null)
{
if (!CanTrack()) return;
var state = _state;
if (!_initialized || !state.Level.CanTrack()) return;
if (string.IsNullOrEmpty(eventName))
{
Log.Warn("Track(string) called with null or empty event name — dropping.");
Expand All @@ -187,10 +191,11 @@ public static void Track(string eventName, Dictionary<string, object>? propertie
var config = _config;
if (config == null) return;

var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, _consent);
var msg = MessageBuilder.Track(eventName, anonymousId, _userId, config.PackageVersion,
var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level);
var userId = state.Level == ConsentLevel.Full ? state.UserId : null;
var msg = MessageBuilder.Track(eventName, anonymousId, userId, config.PackageVersion,
SnapshotCallerDict(properties));
Enqueue(msg);
EnqueueTrack(msg);
}

// -----------------------------------------------------------------
Expand All @@ -213,21 +218,30 @@ public static void Identify(string userId, string identityType, Dictionary<strin
Log.Warn("Identify called with null or empty userId — dropping.");
return;
}
if (!_consent.CanIdentify())

AudienceConfig? config;
ConsentLevel level;
// Update consent + userId under the init lock so they always move
// together — another thread reading _state never sees one half-updated.
lock (_initLock)
{
Log.Warn($"Identify discarded — requires Full consent, current is {_consent}");
return;
if (!_initialized) return;
var current = _state;
level = current.Level;
if (!level.CanIdentify())
{
Log.Warn($"Identify discarded — requires Full consent, current is {level}");
return;
}
config = _config;
if (config == null) return;
_state = current with { UserId = userId };
}

var config = _config;
if (config == null) return;

_userId = userId;

var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, _consent);
var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, level);
var msg = MessageBuilder.Identify(anonymousId, userId, identityType, config.PackageVersion,
SnapshotCallerDict(traits));
Enqueue(msg);
EnqueueIdentity(msg);
}

// Links two user ids for the same player.
Expand All @@ -245,17 +259,18 @@ public static void Alias(string fromId, string fromType, string toId, string toT
Log.Warn("Alias called with null or empty fromId/toId — dropping.");
return;
}
if (!_consent.CanIdentify())
var state = _state;
if (!state.Level.CanIdentify())
{
Log.Warn($"Alias discarded — requires Full consent, current is {_consent}");
Log.Warn($"Alias discarded — requires Full consent, current is {state.Level}");
return;
}

var config = _config;
if (config == null) return;

var msg = MessageBuilder.Alias(fromId, fromType, toId, toType, config.PackageVersion);
Enqueue(msg);
EnqueueIdentity(msg);
}

// Logs out the current player. Clears userId, discards queued events,
Expand All @@ -264,9 +279,10 @@ public static void Alias(string fromId, string fromType, string toId, string toT
// and then purged). Call FlushAsync() first to preserve queued events.
public static void Reset()
{
// Phase 1 under _initLock: swap _session and clear _userId. Blocking
// work (session drain, disk purge, identity wipe, new session_start)
// runs outside the lock so callers racing on _initLock don't wait.
// Phase 1 under _initLock: atomic _state.UserId clear + _session swap.
// Blocking work (session drain, disk purge, identity wipe, new
// session_start) runs outside the lock so callers racing on
// _initLock don't wait.
AudienceConfig? config;
Session? oldSession;
Session? newSession = null;
Expand All @@ -280,11 +296,11 @@ public static void Reset()

oldSession = _session;
queueForPurge = _queue;
_userId = null;
_state = _state with { UserId = null };

// Swap under the lock so racing SetConsent/OnPause/OnResume see
// either the old, the new, or null — never a torn reference.
_session = _consent.CanTrack() ? new Session(Track) : null;
_session = _state.Level.CanTrack() ? new Session(Track) : null;
newSession = _session;
}

Expand Down Expand Up @@ -378,21 +394,21 @@ public static void SetConsent(ConsentLevel level)
if (config == null) return;

// Snapshot check before any I/O: no-op if already at target consent.
var snapshotPrevious = _consent;
var snapshotPrevious = _state.Level;
if (level == snapshotPrevious) return;

// Capture anonymousId for the PUT audit trail outside _initLock.
// Identity methods hold their own _sync lock; disk I/O on a cold
// cache (None → Anonymous/Full upgrade creates the UUID file) does
// not block _initLock. A racing SetConsent may change _consent
// not block _initLock. A racing SetConsent may change _state
// between this read and our lock acquire — acceptable, the racing
// call fires its own PUT and our slightly-stale ID still
// identifies the user.
var anonymousIdForPut = snapshotPrevious == ConsentLevel.None
? Identity.GetOrCreate(config.PersistentDataPath!, level)
: Identity.Get(config.PersistentDataPath!);

// Phase 1 under _initLock: flip _consent and swap _session / _userId.
// Phase 1 under _initLock: atomic _state swap and _session swap.
// Phase 2 outside the lock runs the blocking side effects (persist,
// dispose, purge, downgrade, backend sync, new session_start) so a
// concurrent Shutdown / Init / Reset isn't held waiting on them.
Expand All @@ -410,10 +426,16 @@ public static void SetConsent(ConsentLevel level)
queue = _queue;
if (config == null) return;

previous = _consent;
var previousState = _state;
previous = previousState.Level;
if (level == previous) return;

_consent = level;
// Atomic swap: Level + UserId publish together. Drop UserId on
// any downgrade out of Full so a racing Track/Identify cannot
// observe (Anonymous, oldUserId).
_state = new ConsentState(
level,
level == ConsentLevel.Full ? previousState.UserId : null);

if (level == ConsentLevel.None)
{
Expand All @@ -425,7 +447,6 @@ public static void SetConsent(ConsentLevel level)
}
else if (previous == ConsentLevel.Full && level == ConsentLevel.Anonymous)
{
_userId = null;
downgradeFullToAnonymous = true;
}
else if (previous == ConsentLevel.None && _session == null)
Expand Down Expand Up @@ -468,6 +489,7 @@ public static void SetConsent(ConsentLevel level)

newSession?.Start();


SyncConsentToBackend(config, level, anonymousIdForPut);
}

Expand Down Expand Up @@ -599,7 +621,7 @@ public static void Shutdown()

_config = null;
_store = null;
_userId = null;
_state = _state with { UserId = null };
}

// Phase 2 outside _initLock: end session, drain timers, flush, dispose.
Expand Down Expand Up @@ -667,15 +689,15 @@ internal static void ResetState()

lock (_initLock)
{
_consent = ConsentLevel.None;
_state = ConsentState.None;
// Defensive: Shutdown nulls _session too, but a future refactor
// that bails before that null must not leak a stale Session.
_session = null;
Identity.ClearCache();
}
}

internal static ConsentLevel CurrentConsentForTesting => _consent;
internal static ConsentLevel CurrentConsentForTesting => _state.Level;

internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync();

Expand All @@ -689,23 +711,30 @@ internal static void ResetState()
// Private
// -----------------------------------------------------------------

private static bool CanTrack()
{
return _initialized && _consent.CanTrack();
}

// Copy the dictionary so the caller editing it later can't corrupt the
// message while the background thread is writing it to disk.
// Shallow-copy the caller's dict so a post-call mutation cannot race the drain-thread serialiser.
private static Dictionary<string, object>? SnapshotCallerDict(Dictionary<string, object>? src) =>
src != null ? new Dictionary<string, object>(src) : null;

private static void Enqueue(Dictionary<string, object>? msg)
// Checks the current consent inside the drain lock. If consent has
// since dropped to None the message is discarded. If it dropped to
// Anonymous the userId is stripped.
private static void EnqueueTrack(Dictionary<string, object>? msg)
{
var queue = _queue;
if (queue == null) return;
_queue?.EnqueueChecked(msg, m =>
{
var state = _state;
if (!state.Level.CanTrack()) return null;
if (state.Level != ConsentLevel.Full)
m.Remove(MessageFields.UserId);
return m;
});
}

// Re-check consent inside _drainLock so a racing SetConsent(None) can't leak past the purge.
queue.EnqueueChecked(msg, () => _consent.CanTrack());
// Identify / Alias require Full; drop if consent has downgraded.
private static void EnqueueIdentity(Dictionary<string, object>? msg)
{
_queue?.EnqueueChecked(msg, m =>
_state.Level == ConsentLevel.Full ? m : null);
}

private static void SendBatch()
Expand Down Expand Up @@ -761,7 +790,7 @@ private static void RescheduleSendTimer(HttpTransport transport)
timer.Change(nextMs, sendIntervalMs);
}

// consentAtInit only gates the launch; Track still checks live _consent via CanTrack.
// consentAtInit only gates the launch; Track still checks live _state via CanTrack.
private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit)
{
if (!consentAtInit.CanTrack()) return;
Expand Down
20 changes: 15 additions & 5 deletions src/Packages/Audience/Runtime/Transport/EventQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,26 @@ internal void Enqueue(Dictionary<string, object>? msg)
_flushGate.Set();
}

// Enqueues under _drainLock, re-checking stillAllowed inside the lock.
// Closes the window where a concurrent PurgeAll could complete between
// the caller's check and the enqueue, leaking the event past revocation.
internal void EnqueueChecked(Dictionary<string, object>? msg, Func<bool>? stillAllowed)
// Queues the message under the drain lock. The caller supplies a
// transform that runs while the lock is held — it can edit the
// message or return null to drop it. Running under the lock means
// PurgeAll and ApplyAnonymousDowngrade can't slip in mid-decision, so
// a Track that races a consent downgrade gets its userId stripped or
// the message dropped before it reaches the queue.
internal void EnqueueChecked(
Dictionary<string, object>? msg,
Func<Dictionary<string, object>, Dictionary<string, object>?>? transform)
{
if (_disposed || msg == null) return;

lock (_drainLock)
{
if (stillAllowed != null && !stillAllowed()) return;
if (transform != null)
{
var transformed = transform(msg);
if (transformed == null) return;
msg = transformed;
}
_memory.Enqueue(msg);
}

Expand Down
Loading
Loading