diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 3261bdaa..3e4a9c43 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 b080532d..5679c032 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,8 @@ private static Dictionary BuildBase(string type, string packageV ["library"] = Constants.LibraryName, ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) }, - ["surface"] = Constants.Surface + ["surface"] = Constants.Surface, + [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 85d371fb..26abd726 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,7 @@ private static void EnqueueTrack(Dictionary? msg) { var state = _state; if (!state.Level.CanTrack()) return null; + 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 33ded5be..1b2c4d14 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 && TrackNeedsDowngradeToAnonymous(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 TrackNeedsDowngradeToAnonymous(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,10 @@ 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); + 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 e6bcf40f..92473ddf 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 2517b1e1..96bd2828 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 d3ee554a..a6b7e2cc 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() {