From 6551ce74f581f0a1b710c92ebc4836377e4190e9 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 14:44:39 +1000 Subject: [PATCH 1/6] =?UTF-8?q?fix(audience):=20atomic=20ConsentState=20cl?= =?UTF-8?q?oses=20Full=E2=86=92Anonymous=20userId=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior design had `_consent` and `_userId` as separate volatiles. SetConsent(Full → Anonymous) wrote them in two steps; a concurrent Track that read _userId before the null-write and _consent after the flip could enqueue a userId-bearing track that ApplyAnonymousDowngrade's one-shot disk rewrite never saw. - Pack (Level, UserId) as an immutable ConsentState record. A single volatile reference swap publishes both together; readers see a consistent pair. - EnqueueChecked takes a transform callback that runs under _drainLock. Track's transform re-reads _state and strips userId when the level is not Full, serialised against ApplyAnonymousDowngrade. - Identify, Reset, and SetConsent take _initLock for the state swap so writes don't lose the pair's atomicity. - Identify/Alias enqueue with a gate that drops the event if consent has since downgraded below Full. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/Core/ConsentState.cs | 19 +++ .../Audience/Runtime/ImmutableAudience.cs | 130 +++++++++++------- .../Audience/Runtime/Transport/EventQueue.cs | 19 ++- 3 files changed, 114 insertions(+), 54 deletions(-) create mode 100644 src/Packages/Audience/Runtime/Core/ConsentState.cs diff --git a/src/Packages/Audience/Runtime/Core/ConsentState.cs b/src/Packages/Audience/Runtime/Core/ConsentState.cs new file mode 100644 index 000000000..e85c33238 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/ConsentState.cs @@ -0,0 +1,19 @@ +#nullable enable + +namespace System.Runtime.CompilerServices +{ + // Polyfill: record { init } properties compile to init-only setters that + // reference IsExternalInit. .NET Standard 2.1 (Unity) doesn't ship it. + internal static class IsExternalInit { } +} + +namespace Immutable.Audience +{ + // Immutable consent + userId pair. Stored as a volatile reference so + // readers observe both fields atomically — a SetConsent(Full → Anonymous) + // swap cannot be observed as Anonymous+oldUserId. + internal sealed record ConsentState(ConsentLevel Level, string? UserId) + { + internal static readonly ConsentState None = new(ConsentLevel.None, null); + } +} diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 53027a429..c4649680a 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -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: writes + // happen inside _initLock, reads happen outside. A ConsentState swap + // publishes level + userId together so 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 @@ -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(); @@ -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); @@ -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 @@ -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."); @@ -166,10 +168,11 @@ 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, @@ -177,7 +180,8 @@ public static void Track(IEvent evt) // validates required fields. public static void Track(string eventName, Dictionary? 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."); @@ -187,10 +191,11 @@ public static void Track(string eventName, Dictionary? 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); } // ----------------------------------------------------------------- @@ -213,21 +218,29 @@ public static void Identify(string userId, string identityType, Dictionary _consent; + internal static ConsentLevel CurrentConsentForTesting => _state.Level; internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync(); @@ -691,7 +711,7 @@ internal static void ResetState() private static bool CanTrack() { - return _initialized && _consent.CanTrack(); + return _initialized && _state.Level.CanTrack(); } // Copy the dictionary so the caller editing it later can't corrupt the @@ -699,13 +719,25 @@ private static bool CanTrack() private static Dictionary? SnapshotCallerDict(Dictionary? src) => src != null ? new Dictionary(src) : null; - private static void Enqueue(Dictionary? msg) + // Re-reads _state under _drainLock to close the Track-races-SetConsent + // window: drops on downgrade to None, strips userId on downgrade to Anonymous. + private static void EnqueueTrack(Dictionary? 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? msg) + { + _queue?.EnqueueChecked(msg, m => + _state.Level == ConsentLevel.Full ? m : null); } private static void SendBatch() diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index e81fcde43..e455552b3 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -64,16 +64,25 @@ internal void Enqueue(Dictionary? 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? msg, Func? stillAllowed) + // Enqueues under _drainLock, giving the caller a transform callback + // that runs inside the lock. The transform returns the (possibly + // mutated) message or null to drop. Serialises the decision against + // PurgeAll / ApplyAnonymousDowngrade so consent-race leaks and stale + // userIds can be dropped or stripped atomically. + internal void EnqueueChecked( + Dictionary? msg, + Func, Dictionary?>? 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); } From 9e3f62411f3d79711646714a9719d7da260eaab1 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 14:27:34 +1000 Subject: [PATCH 2/6] =?UTF-8?q?test(audience):=20full=E2=86=92Anonymous=20?= =?UTF-8?q?userId=20race=20stress=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors SetConsent_DowngradeToNone_StressTest_NoLeak but exercises the Full → Anonymous path: an Identify-set userId must not leak through a racing Track past ApplyAnonymousDowngrade. Without the EnqueueChecked transform (commit c14cd391), this leaks reproducibly. With the fix, zero leaks across 200 × 4 iterations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tests/Runtime/ImmutableAudienceTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 8cd9f491d..a79ccb25e 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -748,6 +748,72 @@ public void SetConsent_ConcurrentUpgradeFromNone_StartsOneSession_StressTest() } } + [Test] + public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() + { + // Full → Anonymous race: Track reads _state with userId still set, + // then SetConsent flips _state to Anonymous and calls + // ApplyAnonymousDowngrade (one-shot rewrite). If Track's enqueue + // lands after the rewrite, the msg with userId is not stripped. + // + // With the ConsentState + EnqueueChecked transform in place, Track's + // transform runs under _drainLock and strips userId when current state + // is not Full. Zero leaks across all iterations. + // + // Sabotage: remove the `m.Remove(MessageFields.UserId)` in + // EnqueueTrack and this test leaks reproducibly. + const int iterations = 200; + const int trackersPerIteration = 4; + const string testUserId = "user_race_stress"; + + for (int iter = 0; iter < iterations; iter++) + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Identify(testUserId, "steam"); + + // Clear Init events so only race events can leak. + ImmutableAudience.FlushQueueToDiskForTesting(); + var queueDir = AudiencePaths.QueueDir(_testDir); + if (Directory.Exists(queueDir)) + foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + + var barrier = new Barrier(trackersPerIteration + 1); + var trackers = new Task[trackersPerIteration]; + for (int t = 0; t < trackersPerIteration; t++) + { + trackers[t] = Task.Run(() => + { + barrier.SignalAndWait(); + ImmutableAudience.Track("race_stress"); + }); + } + + barrier.SignalAndWait(); + ImmutableAudience.SetConsent(ConsentLevel.Anonymous); + Task.WaitAll(trackers, TimeSpan.FromSeconds(5)); + + ImmutableAudience.FlushQueueToDiskForTesting(); + + int userIdLeaks = 0; + if (Directory.Exists(queueDir)) + { + userIdLeaks = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText) + .Count(c => c.Contains($"\"{testUserId}\"")); + } + + if (userIdLeaks > 0) + { + Assert.Fail( + $"iteration {iter}: {userIdLeaks} track events retained userId past SetConsent(Anonymous)"); + } + + ImmutableAudience.ResetState(); + if (Directory.Exists(AudiencePaths.AudienceDir(_testDir))) + Directory.Delete(AudiencePaths.AudienceDir(_testDir), recursive: true); + } + } + [Test] public void ResetState_ClearsIdentityCache_AcrossInitWithDifferentPath() { From 32f5aa18d3d92cbc1ccfb37c28764b5f256ceea4 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 15:17:58 +1000 Subject: [PATCH 3/6] docs(audience): plain-language comment pass Rewrites jargon-heavy comments (polyfill, atomic reference, release/ acquire, drain-lock, serialise) into plain language. No code changes. All 181 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/Core/ConsentState.cs | 11 ++++++----- .../Audience/Runtime/ImmutableAudience.cs | 16 +++++++++------- .../Audience/Runtime/Transport/EventQueue.cs | 11 ++++++----- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/ConsentState.cs b/src/Packages/Audience/Runtime/Core/ConsentState.cs index e85c33238..d5a7aacce 100644 --- a/src/Packages/Audience/Runtime/Core/ConsentState.cs +++ b/src/Packages/Audience/Runtime/Core/ConsentState.cs @@ -2,16 +2,17 @@ namespace System.Runtime.CompilerServices { - // Polyfill: record { init } properties compile to init-only setters that - // reference IsExternalInit. .NET Standard 2.1 (Unity) doesn't ship it. + // 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 { - // Immutable consent + userId pair. Stored as a volatile reference so - // readers observe both fields atomically — a SetConsent(Full → Anonymous) - // swap cannot be observed as Anonymous+oldUserId. + // 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); diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index c4649680a..648acc9fe 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -14,10 +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. - // _state (consent level + userId) and _session are volatile: writes - // happen inside _initLock, reads happen outside. A ConsentState swap - // publishes level + userId together so callers never observe - // (Anonymous, oldUserId). + // _state (consent level + userId) and _session are volatile so a write + // on one thread is visible on any other. Writes happen under _initLock + // (Identify / Reset / SetConsent also take it) 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 @@ -221,7 +221,8 @@ public static void Identify(string userId, string identityType, Dictionary? SnapshotCallerDict(Dictionary? src) => src != null ? new Dictionary(src) : null; - // Re-reads _state under _drainLock to close the Track-races-SetConsent - // window: drops on downgrade to None, strips userId on downgrade to Anonymous. + // 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? msg) { _queue?.EnqueueChecked(msg, m => diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index e455552b3..66922e3f0 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -64,11 +64,12 @@ internal void Enqueue(Dictionary? msg) _flushGate.Set(); } - // Enqueues under _drainLock, giving the caller a transform callback - // that runs inside the lock. The transform returns the (possibly - // mutated) message or null to drop. Serialises the decision against - // PurgeAll / ApplyAnonymousDowngrade so consent-race leaks and stale - // userIds can be dropped or stripped atomically. + // 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? msg, Func, Dictionary?>? transform) From 5882f8b84c1f9378734dee4971dad214712abd63 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 16:23:38 +1000 Subject: [PATCH 4/6] refactor(audience): remove unused private CanTrack helper Dead code: every callsite uses the extension method ConsentLevel.CanTrack() directly. No call to the private static CanTrack() remained. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 648acc9fe..f0925f981 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -710,13 +710,7 @@ internal static void ResetState() // Private // ----------------------------------------------------------------- - private static bool CanTrack() - { - return _initialized && _state.Level.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? SnapshotCallerDict(Dictionary? src) => src != null ? new Dictionary(src) : null; From 59902df6cf6068d7f623241eb01e7cc99db2ebda Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 16:37:15 +1000 Subject: [PATCH 5/6] docs(audience): drop stale _consent refs and tighten _state lock comment Two comments still named the pre-refactor _consent field; one class-level comment enumerated only Identify / Reset as taking _initLock, which is now true of every _state write. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 8 ++++---- .../Audience/Tests/Runtime/ImmutableAudienceTests.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index f0925f981..6329cedf5 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -15,9 +15,9 @@ 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. // _state (consent level + userId) and _session are volatile so a write - // on one thread is visible on any other. Writes happen under _initLock - // (Identify / Reset / SetConsent also take it) so level and userId - // always move together — callers never observe (Anonymous, oldUserId). + // 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 @@ -789,7 +789,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; diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index a79ccb25e..e71f19c76 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -568,7 +568,7 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() // before SetConsent starts on a fast machine. This stress variant runs // the race many times with many concurrent Track threads so at least // some iterations are guaranteed to land the enqueue inside the - // _consent=None/PurgeAll window. + // _state=None/PurgeAll window. // // Without the EnqueueChecked re-check, this test leaks events // reproducibly. With the fix, zero leaks across all iterations. From 815c563f17721f9762ae5b234c83840c8cbfa3b3 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 18:27:11 +1000 Subject: [PATCH 6/6] perf(audience): move SyncConsentToBackend out of _initLock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires the fire-and-forget consent PUT after the lock releases. JSON serialisation, dictionary allocation, and Task.Run scheduling no longer block concurrent SetConsent / Identify / Reset callers. Why: the call's three args are already local snapshots and the control client it reads is not _initLock-protected anyway (Shutdown disposes it without the lock, relying on _shutdownCancellationSource for safety). Tests: all 181 audience unit tests pass. No test changes — Identity.Reset still runs before the PUT, so the revocation regression guard still holds. Addresses Cursor Bugbot comment on immutable/unity-immutable-sdk#700. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 6329cedf5..9eaa98503 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -489,6 +489,7 @@ public static void SetConsent(ConsentLevel level) newSession?.Start(); + SyncConsentToBackend(config, level, anonymousIdForPut); }