From e88c9cbb537055cc4a084607ceb952fcba3df5d8 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 15 Apr 2026 12:22:51 +1200 Subject: [PATCH 1/2] feat(audience): add MessageBuilder for wire-format message construction (SDK-129) --- .../Audience/Runtime/Events/MessageBuilder.cs | 92 ++++++++++++++++ .../Tests/Runtime/MessageBuilderTests.cs | 101 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/Packages/Audience/Runtime/Events/MessageBuilder.cs create mode 100644 src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs new file mode 100644 index 000000000..38d1998ac --- /dev/null +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; + +namespace Immutable.Audience +{ + internal static class MessageBuilder + { + internal static Dictionary Track( + string eventName, + string anonymousId, + string userId, + string packageVersion, + Dictionary properties = null) + { + var msg = BuildBase("track", packageVersion); + msg["eventName"] = Truncate(eventName, 256); + + if (!string.IsNullOrEmpty(anonymousId)) + msg["anonymousId"] = Truncate(anonymousId, 256); + + if (!string.IsNullOrEmpty(userId)) + msg["userId"] = Truncate(userId, 256); + + if (properties != null && properties.Count > 0) + msg["properties"] = properties; + + return msg; + } + + internal static Dictionary Identify( + string anonymousId, + string userId, + string identityType, + string packageVersion, + Dictionary traits = null) + { + var msg = BuildBase("identify", packageVersion); + + if (!string.IsNullOrEmpty(anonymousId)) + msg["anonymousId"] = Truncate(anonymousId, 256); + + if (!string.IsNullOrEmpty(userId)) + msg["userId"] = Truncate(userId, 256); + + if (!string.IsNullOrEmpty(identityType)) + msg["identityType"] = identityType; + + if (traits != null && traits.Count > 0) + msg["traits"] = traits; + + return msg; + } + + internal static Dictionary Alias( + string fromId, + string fromType, + string toId, + string toType, + string packageVersion) + { + var msg = BuildBase("alias", packageVersion); + msg["fromId"] = Truncate(fromId, 256); + msg["fromType"] = fromType; + msg["toId"] = Truncate(toId, 256); + msg["toType"] = toType; + return msg; + } + + private static Dictionary BuildBase(string type, string packageVersion) + { + return new Dictionary + { + ["type"] = type, + ["messageId"] = Guid.NewGuid().ToString(), + ["eventTimestamp"] = DateTime.UtcNow.ToString("o"), + ["context"] = new Dictionary + { + ["library"] = Constants.LibraryName, + ["libraryVersion"] = Truncate(packageVersion, 256) + }, + ["surface"] = Constants.Surface + }; + } + + private static string Truncate(string s, int maxLen) + { + if (s == null || s.Length <= maxLen) + return s; + return s.Substring(0, maxLen); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs new file mode 100644 index 000000000..bd3aad3cc --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + public class MessageBuilderTests + { + private const string PackageVersion = "1.2.3"; + + [Test] + public void Track_RequiredFieldsPresent() + { + var result = MessageBuilder.Track("level_complete", "anon-1", null, PackageVersion); + + Assert.AreEqual("track", result["type"]); + Assert.IsTrue(result.ContainsKey("messageId")); + Assert.IsTrue(result.ContainsKey("eventTimestamp")); + Assert.IsTrue(result.ContainsKey("context")); + Assert.IsTrue(result.ContainsKey("surface")); + Assert.AreEqual("level_complete", result["eventName"]); + } + + [Test] + public void Track_EventNameLongerThan256Chars_TruncatedTo256() + { + var longName = new string('x', 300); + + var result = MessageBuilder.Track(longName, null, null, PackageVersion); + + Assert.AreEqual(256, ((string)result["eventName"]).Length); + } + + [Test] + public void Track_NullUserId_NotPresentInDict() + { + var result = MessageBuilder.Track("evt", "anon-1", null, PackageVersion); + + Assert.IsFalse(result.ContainsKey("userId")); + } + + [Test] + public void Track_NonNullUserId_PresentInDict() + { + var result = MessageBuilder.Track("evt", "anon-1", "user-99", PackageVersion); + + Assert.IsTrue(result.ContainsKey("userId")); + Assert.AreEqual("user-99", result["userId"]); + } + + [Test] + public void Identify_TypeAndIdentityFieldsPresent() + { + var result = MessageBuilder.Identify("anon-42", "user-42", "steam", PackageVersion); + + Assert.AreEqual("identify", result["type"]); + Assert.AreEqual("anon-42", result["anonymousId"]); + Assert.AreEqual("user-42", result["userId"]); + Assert.AreEqual("steam", result["identityType"]); + } + + [Test] + public void Alias_AllFourFieldsPresent() + { + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", PackageVersion); + + Assert.AreEqual("alias", result["type"]); + Assert.AreEqual("from-id", result["fromId"]); + Assert.AreEqual("email", result["fromType"]); + Assert.AreEqual("to-id", result["toId"]); + Assert.AreEqual("steam", result["toType"]); + } + + [Test] + public void AllMessages_ContextContainsLibraryAndLibraryVersion() + { + var track = MessageBuilder.Track("evt", null, null, PackageVersion); + var identify = MessageBuilder.Identify(null, "u1", null, PackageVersion); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); + + foreach (var msg in new[] { track, identify, alias }) + { + var ctx = (Dictionary)msg["context"]; + Assert.AreEqual(Constants.LibraryName, ctx["library"]); + Assert.AreEqual(PackageVersion, ctx["libraryVersion"]); + } + } + + [Test] + public void AllMessages_SurfaceIsUnity() + { + var track = MessageBuilder.Track("evt", null, null, PackageVersion); + var identify = MessageBuilder.Identify(null, "u1", null, PackageVersion); + var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); + + Assert.AreEqual("unity", track["surface"]); + Assert.AreEqual("unity", identify["surface"]); + Assert.AreEqual("unity", alias["surface"]); + } + } +} From 415a800a635f0d8a9d7d909782eb7b4bd7fb8d2b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 17 Apr 2026 11:24:35 +1000 Subject: [PATCH 2/2] fix(audience): truncate identityType, fromType, toType to 256 chars Addresses cursor[bot] review comment: every other user-supplied string field in MessageBuilder (eventName, anonymousId, userId, fromId, toId, libraryVersion) is truncated to 256 chars per the backend schema. The three identity-type fields were unintentionally exempt. While the plan's IdentityType values are fixed strings that fit within 256 chars, applying Truncate consistently matches the stated behavior and prevents future surprise if callers pass arbitrary type strings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Packages/Audience/Runtime/Events/MessageBuilder.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 38d1998ac..efc3ec396 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -43,7 +43,7 @@ internal static Dictionary Identify( msg["userId"] = Truncate(userId, 256); if (!string.IsNullOrEmpty(identityType)) - msg["identityType"] = identityType; + msg["identityType"] = Truncate(identityType, 256); if (traits != null && traits.Count > 0) msg["traits"] = traits; @@ -60,9 +60,9 @@ internal static Dictionary Alias( { var msg = BuildBase("alias", packageVersion); msg["fromId"] = Truncate(fromId, 256); - msg["fromType"] = fromType; + msg["fromType"] = Truncate(fromType, 256); msg["toId"] = Truncate(toId, 256); - msg["toType"] = toType; + msg["toType"] = Truncate(toType, 256); return msg; }