From d08e16cd9467a1a8e700bd67159e093ae30daf07 Mon Sep 17 00:00:00 2001 From: John Carlo San Pedro Date: Thu, 2 Jul 2026 13:49:07 +1200 Subject: [PATCH 1/2] feat(audience): stamp consentLevel on Unity SDK messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamp the current consent level onto every message in MessageBuilder.BuildBase (threaded through Track/Identify/Alias), so the backend records the explicit level rather than inferring it from userId presence. Every build/enqueue path is consent-guarded, so only decided levels (anonymous/full) are ever stamped. On a Full -> Anonymous downgrade, queued track messages now have consentLevel rewritten to anonymous alongside the existing userId strip — in the in-memory EnqueueChecked transform and the on-disk DiskStore rewrite (which also normalises events persisted before consentLevel existed). Mirrors the web/pixel SDK change (SDK-565) and the audience-service ingest side (SDK-554). Co-authored-by: Cursor --- .../Audience/Runtime/Core/Constants.cs | 1 + .../Audience/Runtime/Events/MessageBuilder.cs | 21 +++-- .../Audience/Runtime/ImmutableAudience.cs | 13 +++- .../Audience/Runtime/Transport/DiskStore.cs | 22 +++++- .../Runtime/Events/MessageBuilderTests.cs | 77 +++++++++++++------ .../Tests/Runtime/ImmutableAudienceTests.cs | 24 ++++++ .../Tests/Runtime/Transport/DiskStoreTests.cs | 23 +++++- 7 files changed, 142 insertions(+), 39 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 3261bdaa6..3e4a9c438 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -53,6 +53,7 @@ internal static class MessageFields internal const string Type = "type"; internal const string UserId = "userId"; internal const string DeviceId = "deviceId"; + internal const string ConsentLevel = "consentLevel"; } /// diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index b080532dd..dea7c929d 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -13,10 +13,11 @@ internal static Dictionary Track( string? userId, string? deviceId, string packageVersion, + string consentLevel, Dictionary? properties = null, bool testMode = false) { - var msg = BuildBase(MessageTypes.Track, packageVersion, testMode); + var msg = BuildBase(MessageTypes.Track, packageVersion, consentLevel, testMode); msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(anonymousId)) @@ -43,10 +44,11 @@ internal static Dictionary Identify( string? deviceId, string identityType, string packageVersion, + string consentLevel, Dictionary? traits = null, bool testMode = false) { - var msg = BuildBase(MessageTypes.Identify, packageVersion, testMode); + var msg = BuildBase(MessageTypes.Identify, packageVersion, consentLevel, testMode); if (!string.IsNullOrEmpty(anonymousId)) msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); @@ -75,9 +77,10 @@ internal static Dictionary Alias( string toType, string? deviceId, string packageVersion, + string consentLevel, bool testMode = false) { - var msg = BuildBase(MessageTypes.Alias, packageVersion, testMode); + var msg = BuildBase(MessageTypes.Alias, packageVersion, consentLevel, testMode); msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength); msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength); msg["toId"] = Truncate(toId, Constants.MaxFieldLength); @@ -89,7 +92,8 @@ internal static Dictionary Alias( return msg; } - private static Dictionary BuildBase(string type, string packageVersion, bool testMode) + private static Dictionary BuildBase( + string type, string packageVersion, string consentLevel, bool testMode) { var msg = new Dictionary { @@ -101,7 +105,14 @@ private static Dictionary BuildBase(string type, string packageV ["library"] = Constants.LibraryName, ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) }, - ["surface"] = Constants.Surface + ["surface"] = Constants.Surface, + // Consent level under which this event was collected. Stamped so + // the backend records the explicit level rather than inferring it + // from userId presence (which can't tell full-but-unidentified + // traffic from anonymous). Callers only build messages under a + // decided level (anonymous/full); none-consent events are never + // emitted. + [MessageFields.ConsentLevel] = consentLevel }; if (testMode) msg["test"] = true; diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 85d371fbc..8a72df99d 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -354,7 +354,8 @@ public static void Track(IEvent evt) var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); // ToProperties returns a fresh dict per call, so no snapshot needed. var userId = state.Level == ConsentLevel.Full ? state.UserId : null; - var msg = MessageBuilder.Track(eventName, anonymousId, userId, deviceId, Constants.LibraryVersion, properties, config.TestMode); + var msg = MessageBuilder.Track(eventName, anonymousId, userId, deviceId, Constants.LibraryVersion, + state.Level.ToLowercaseString(), properties, config.TestMode); EnqueueTrack(msg); } @@ -382,7 +383,7 @@ public static void Track(string eventName, Dictionary? propertie var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); var userId = state.Level == ConsentLevel.Full ? state.UserId : null; var msg = MessageBuilder.Track(eventName, anonymousId, userId, deviceId, Constants.LibraryVersion, - SnapshotCallerDict(properties), config.TestMode); + state.Level.ToLowercaseString(), SnapshotCallerDict(properties), config.TestMode); EnqueueTrack(msg); } @@ -429,7 +430,7 @@ public static void Identify(string userId, IdentityType identityType, Dictionary var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, level); var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, level); var msg = MessageBuilder.Identify(anonymousId, userId, deviceId, identityType.ToLowercaseString(), - Constants.LibraryVersion, SnapshotCallerDict(traits), config.TestMode); + Constants.LibraryVersion, level.ToLowercaseString(), SnapshotCallerDict(traits), config.TestMode); EnqueueIdentity(msg); } @@ -461,7 +462,7 @@ public static void Alias(string fromId, IdentityType fromType, string toId, Iden var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); var msg = MessageBuilder.Alias(fromId, fromType.ToLowercaseString(), toId, toType.ToLowercaseString(), - deviceId, Constants.LibraryVersion, config.TestMode); + deviceId, Constants.LibraryVersion, state.Level.ToLowercaseString(), config.TestMode); EnqueueIdentity(msg); } @@ -1018,6 +1019,10 @@ private static void EnqueueTrack(Dictionary? msg) { var state = _state; if (!state.Level.CanTrack()) return null; + // Re-stamp under the drain lock so consentLevel reflects the level + // at enqueue time, not the (possibly higher) level when the message + // was built — the userId strip below relies on the same check. + m[MessageFields.ConsentLevel] = state.Level.ToLowercaseString(); if (state.Level != ConsentLevel.Full) m.Remove(MessageFields.UserId); return m; diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index 33ded5be8..9c6fcafa0 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -147,8 +147,20 @@ private void ApplyAnonymousDowngradeToFile(string path) return; } - if (type == MessageTypes.Track && msg.ContainsKey(MessageFields.UserId)) - RewriteTrackWithoutUserId(path, msg); + if (type == MessageTypes.Track && TrackNeedsDowngrade(msg)) + RewriteTrackForAnonymous(path, msg); + } + + // A queued track needs rewriting on a Full -> Anonymous downgrade if it + // still carries a userId or a consentLevel other than "anonymous". The + // check keeps already-anonymous messages untouched so a fail-closed + // rewrite error can only ever discard events that actually had to change. + private static bool TrackNeedsDowngrade(Dictionary msg) + { + if (msg.ContainsKey(MessageFields.UserId)) return true; + return !(msg.TryGetValue(MessageFields.ConsentLevel, out var cl) + && cl is string s + && s == ConsentLevel.Anonymous.ToLowercaseString()); } private static bool IsIdentityMessage(string type) => @@ -168,9 +180,13 @@ private static bool TryReadMessage(string path, [NotNullWhen(true)] out Dictiona return true; } - private void RewriteTrackWithoutUserId(string path, Dictionary msg) + private void RewriteTrackForAnonymous(string path, Dictionary msg) { msg.Remove(MessageFields.UserId); + // Normalise the stamped consent so a downgraded event never still + // reports "full"; also covers events persisted before consentLevel + // existed (they gain "anonymous" rather than staying unset). + msg[MessageFields.ConsentLevel] = ConsentLevel.Anonymous.ToLowercaseString(); try { diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index e6bcf40fa..92473ddf8 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -11,11 +11,12 @@ public class MessageBuilderTests private const string PackageVersion = "1.2.3"; private const string AnonId = "anon-1"; private const string DeviceId = "device-1"; + private const string Consent = "anonymous"; [Test] public void Track_RequiredFieldsPresent() { - var result = MessageBuilder.Track("level_complete", AnonId, null, null, PackageVersion); + var result = MessageBuilder.Track("level_complete", AnonId, null, null, PackageVersion, Consent); Assert.AreEqual("track", result["type"]); Assert.IsTrue(result.ContainsKey("messageId")); @@ -30,7 +31,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() { var longName = new string('x', 300); - var result = MessageBuilder.Track(longName, null, null, null, PackageVersion); + var result = MessageBuilder.Track(longName, null, null, null, PackageVersion, Consent); Assert.AreEqual(256, ((string)result["eventName"]).Length); } @@ -38,7 +39,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() [Test] public void Track_NullUserId_NotPresentInDict() { - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion); + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent); Assert.IsFalse(result.ContainsKey("userId")); } @@ -46,7 +47,7 @@ public void Track_NullUserId_NotPresentInDict() [Test] public void Track_NonNullUserId_PresentInDict() { - var result = MessageBuilder.Track("evt", AnonId, "user-99", null, PackageVersion); + var result = MessageBuilder.Track("evt", AnonId, "user-99", null, PackageVersion, Consent); Assert.IsTrue(result.ContainsKey("userId")); Assert.AreEqual("user-99", result["userId"]); @@ -55,7 +56,7 @@ public void Track_NonNullUserId_PresentInDict() [Test] public void Track_DeviceId_PresentWhenProvided() { - var result = MessageBuilder.Track("evt", AnonId, null, DeviceId, PackageVersion); + var result = MessageBuilder.Track("evt", AnonId, null, DeviceId, PackageVersion, Consent); Assert.IsTrue(result.ContainsKey("deviceId")); Assert.AreEqual(DeviceId, result["deviceId"]); @@ -64,7 +65,7 @@ public void Track_DeviceId_PresentWhenProvided() [Test] public void Track_DeviceId_AbsentWhenNull() { - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion); + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent); Assert.IsFalse(result.ContainsKey("deviceId")); } @@ -72,7 +73,7 @@ public void Track_DeviceId_AbsentWhenNull() [Test] public void Identify_TypeAndIdentityFieldsPresent() { - var result = MessageBuilder.Identify("anon-42", "user-42", null, "steam", PackageVersion); + var result = MessageBuilder.Identify("anon-42", "user-42", null, "steam", PackageVersion, "full"); Assert.AreEqual("identify", result["type"]); Assert.AreEqual("anon-42", result["anonymousId"]); @@ -83,7 +84,7 @@ public void Identify_TypeAndIdentityFieldsPresent() [Test] public void Identify_DeviceId_PresentWhenProvided() { - var result = MessageBuilder.Identify("anon-42", "user-42", DeviceId, "steam", PackageVersion); + var result = MessageBuilder.Identify("anon-42", "user-42", DeviceId, "steam", PackageVersion, "full"); Assert.IsTrue(result.ContainsKey("deviceId")); Assert.AreEqual(DeviceId, result["deviceId"]); @@ -92,7 +93,7 @@ public void Identify_DeviceId_PresentWhenProvided() [Test] public void Alias_AllFourFieldsPresent() { - var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", null, PackageVersion); + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", null, PackageVersion, "full"); Assert.AreEqual("alias", result["type"]); Assert.AreEqual("from-id", result["fromId"]); @@ -104,7 +105,7 @@ public void Alias_AllFourFieldsPresent() [Test] public void Alias_DeviceId_PresentWhenProvided() { - var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", DeviceId, PackageVersion); + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", DeviceId, PackageVersion, "full"); Assert.IsTrue(result.ContainsKey("deviceId")); Assert.AreEqual(DeviceId, result["deviceId"]); @@ -113,9 +114,9 @@ public void Alias_DeviceId_PresentWhenProvided() [Test] public void AllMessages_ContextContainsLibraryAndLibraryVersion() { - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion); - var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion); - var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion); + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); foreach (var msg in new[] { track, identify, alias }) { @@ -128,15 +129,41 @@ public void AllMessages_ContextContainsLibraryAndLibraryVersion() [Test] public void AllMessages_SurfaceIsUnity() { - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion); - var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion); - var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion); + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); Assert.AreEqual("unity", track["surface"]); Assert.AreEqual("unity", identify["surface"]); Assert.AreEqual("unity", alias["surface"]); } + [Test] + public void AllMessages_ConsentLevelStamped() + { + // Every message carries the consent level it was built under, so the + // backend records the explicit level instead of inferring it. + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, "anonymous"); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); + + Assert.AreEqual("anonymous", track["consentLevel"]); + Assert.AreEqual("full", identify["consentLevel"]); + Assert.AreEqual("full", alias["consentLevel"]); + } + + [Test] + public void Track_ConsentLevelFull_NoUserId_IsFullButUnidentified() + { + // Full consent does not require a userId (e.g. before Identify()); the + // explicit consentLevel is exactly what distinguishes this from + // anonymous traffic. + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, "full"); + + Assert.AreEqual("full", result["consentLevel"]); + Assert.IsFalse(result.ContainsKey("userId")); + } + [Test] public void AllMessages_MessageId_ParsesAsGuid() { @@ -154,7 +181,7 @@ public void Track_MessageId_IsUniquePerCall() // Backend deduplicates on messageId; collisions silently drop events. var ids = new HashSet(); for (var i = 0; i < 1000; i++) - ids.Add((string)MessageBuilder.Track("evt", null, null, null, PackageVersion)["messageId"]); + ids.Add((string)MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent)["messageId"]); Assert.AreEqual(1000, ids.Count); } @@ -196,7 +223,7 @@ public void AllMessages_Context_LibraryAndLibraryVersionAreNonEmptyStrings() [Test] public void Track_TestModeTrue_IncludesTestFlag() { - var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, testMode: true); + var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, testMode: true); Assert.IsTrue(result.ContainsKey("test"), "test field must be present when testMode is true"); Assert.AreEqual(true, result["test"]); } @@ -204,16 +231,16 @@ public void Track_TestModeTrue_IncludesTestFlag() [Test] public void Track_TestModeFalse_ExcludesTestFlag() { - var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, testMode: false); + var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, testMode: false); Assert.IsFalse(result.ContainsKey("test"), "test field must not be present when testMode is false"); } [Test] public void AllMessages_TestModeTrue_AllIncludeTestFlag() { - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, testMode: true); - var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, testMode: true); - var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, testMode: true); + var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, testMode: true); + var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full", testMode: true); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full", testMode: true); foreach (var msg in new[] { track, identify, alias }) { @@ -224,9 +251,9 @@ public void AllMessages_TestModeTrue_AllIncludeTestFlag() private static IEnumerable> EveryMessageType() { - yield return MessageBuilder.Track("evt", null, null, null, PackageVersion); - yield return MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion); - yield return MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion); + yield return MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent); + yield return MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); + yield return MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); } } } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 2517b1e12..96bd28284 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -1976,7 +1976,11 @@ public void FullToAnonymous_StripsUserIdFromQueuedTrackAndDropsIdentifyAlias() Assert.AreNotEqual("identify", type, "identify must be purged on Full -> Anonymous"); Assert.AreNotEqual("alias", type, "alias must be purged on Full -> Anonymous"); if (type == "track") + { Assert.IsFalse(msg.ContainsKey("userId"), "userId must be stripped from queued track on Full -> Anonymous"); + Assert.AreEqual("anonymous", msg["consentLevel"], + "consentLevel must be downgraded to anonymous on queued track"); + } } } @@ -2001,6 +2005,26 @@ public void FullToAnonymous_FutureTracksOmitUserId() Assert.AreEqual(1, trackFiles.Count); Assert.IsFalse(trackFiles[0].ContainsKey("userId"), "Track under Anonymous consent must not carry userId"); + Assert.AreEqual("anonymous", trackFiles[0]["consentLevel"], + "Track under Anonymous consent must stamp consentLevel anonymous"); + } + + [Test] + public void FullConsent_TrackStampsConsentLevelFull() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Track("tracked_under_full"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var track = Directory.GetFiles(queueDir, "*.json") + .Select(f => JsonReader.DeserializeObject(File.ReadAllText(f))) + .First(m => (string)m["type"] == "track" + && m.ContainsKey("eventName") + && (string)m["eventName"] == "tracked_under_full"); + + Assert.AreEqual("full", track["consentLevel"], + "Track under Full consent must stamp consentLevel full"); } // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs index d3ee554a5..a6b7e2cc2 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs @@ -166,8 +166,8 @@ public void ApplyAnonymousDowngrade_DeletesIdentifyAndAlias_StripsUserIdFromTrac { _store.Write("{\"type\":\"identify\",\"anonymousId\":\"a\",\"userId\":\"u\"}"); _store.Write("{\"type\":\"alias\",\"fromId\":\"a\",\"toId\":\"u\"}"); - _store.Write("{\"type\":\"track\",\"eventName\":\"x\",\"anonymousId\":\"a\",\"userId\":\"u\"}"); - _store.Write("{\"type\":\"track\",\"eventName\":\"y\",\"anonymousId\":\"a\"}"); + _store.Write("{\"type\":\"track\",\"eventName\":\"x\",\"anonymousId\":\"a\",\"userId\":\"u\",\"consentLevel\":\"full\"}"); + _store.Write("{\"type\":\"track\",\"eventName\":\"y\",\"anonymousId\":\"a\",\"consentLevel\":\"full\"}"); _store.ApplyAnonymousDowngrade(); @@ -180,9 +180,28 @@ public void ApplyAnonymousDowngrade_DeletesIdentifyAndAlias_StripsUserIdFromTrac var msg = JsonReader.DeserializeObject(json); Assert.AreEqual("track", msg["type"]); Assert.IsFalse(msg.ContainsKey("userId"), "userId must be stripped from queued track messages"); + // consentLevel must be normalised to anonymous, even for the track + // that never carried a userId (full-but-unidentified). + Assert.AreEqual("anonymous", msg["consentLevel"], + "consentLevel must be downgraded to anonymous on queued track messages"); } } + [Test] + public void ApplyAnonymousDowngrade_TrackWithoutConsentLevel_GainsAnonymous() + { + // A track persisted by a pre-consentLevel build (no consentLevel field) + // is normalised to anonymous on downgrade rather than left unset. + _store.Write("{\"type\":\"track\",\"eventName\":\"legacy\",\"anonymousId\":\"a\"}"); + + _store.ApplyAnonymousDowngrade(); + + var remaining = _store.ReadBatch(10); + Assert.AreEqual(1, remaining.Count); + var msg = JsonReader.DeserializeObject(File.ReadAllText(remaining[0])); + Assert.AreEqual("anonymous", msg["consentLevel"]); + } + [Test] public void ApplyAnonymousDowngrade_PurchaseValue_RoundsTripsExactlyForRealisticPrices() { From 55348f6131db04ddd4b1bb40621222b1bbbb0d91 Mon Sep 17 00:00:00 2001 From: John Carlo San Pedro Date: Thu, 2 Jul 2026 14:29:31 +1200 Subject: [PATCH 2/2] refactor: naming and comments --- src/Packages/Audience/Runtime/Events/MessageBuilder.cs | 6 ------ src/Packages/Audience/Runtime/ImmutableAudience.cs | 3 --- src/Packages/Audience/Runtime/Transport/DiskStore.cs | 7 ++----- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index dea7c929d..5679c032d 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -106,12 +106,6 @@ private static Dictionary BuildBase( ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) }, ["surface"] = Constants.Surface, - // Consent level under which this event was collected. Stamped so - // the backend records the explicit level rather than inferring it - // from userId presence (which can't tell full-but-unidentified - // traffic from anonymous). Callers only build messages under a - // decided level (anonymous/full); none-consent events are never - // emitted. [MessageFields.ConsentLevel] = consentLevel }; if (testMode) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 8a72df99d..26abd7267 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -1019,9 +1019,6 @@ private static void EnqueueTrack(Dictionary? msg) { var state = _state; if (!state.Level.CanTrack()) return null; - // Re-stamp under the drain lock so consentLevel reflects the level - // at enqueue time, not the (possibly higher) level when the message - // was built — the userId strip below relies on the same check. m[MessageFields.ConsentLevel] = state.Level.ToLowercaseString(); if (state.Level != ConsentLevel.Full) m.Remove(MessageFields.UserId); diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index 9c6fcafa0..1b2c4d148 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -147,7 +147,7 @@ private void ApplyAnonymousDowngradeToFile(string path) return; } - if (type == MessageTypes.Track && TrackNeedsDowngrade(msg)) + if (type == MessageTypes.Track && TrackNeedsDowngradeToAnonymous(msg)) RewriteTrackForAnonymous(path, msg); } @@ -155,7 +155,7 @@ private void ApplyAnonymousDowngradeToFile(string path) // still carries a userId or a consentLevel other than "anonymous". The // check keeps already-anonymous messages untouched so a fail-closed // rewrite error can only ever discard events that actually had to change. - private static bool TrackNeedsDowngrade(Dictionary msg) + private static bool TrackNeedsDowngradeToAnonymous(Dictionary msg) { if (msg.ContainsKey(MessageFields.UserId)) return true; return !(msg.TryGetValue(MessageFields.ConsentLevel, out var cl) @@ -183,9 +183,6 @@ private static bool TryReadMessage(string path, [NotNullWhen(true)] out Dictiona private void RewriteTrackForAnonymous(string path, Dictionary msg) { msg.Remove(MessageFields.UserId); - // Normalise the stamped consent so a downgraded event never still - // reports "full"; also covers events persisted before consentLevel - // existed (they gain "anonymous" rather than staying unset). msg[MessageFields.ConsentLevel] = ConsentLevel.Anonymous.ToLowercaseString(); try