From 9a0c1ef149374333781a53a46faa7f2f956c2150 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Tue, 7 Jul 2026 14:49:45 +1200 Subject: [PATCH] Attach sessionId to every outbound Audience message envelope session_id was previously only attached to session_start/heartbeat/end via manual properties writes, leaving custom Track calls, Identify, Alias, and built-ins like game_launch with no session correlation. Session now passes its captured session id through the TrackDelegate so ImmutableAudience can stamp a sessionId field on every message envelope automatically, including for session_end where the id must come from the value captured before Session resets its live state. --- .../Audience/Runtime/Core/Constants.cs | 1 + src/Packages/Audience/Runtime/Core/Session.cs | 17 ++-- .../Audience/Runtime/Events/MessageBuilder.cs | 12 +++ .../Audience/Runtime/ImmutableAudience.cs | 47 +++++++---- .../Tests/Runtime/Core/SessionTests.cs | 80 ++++++++++++++++-- .../Runtime/Events/MessageBuilderTests.cs | 69 +++++++++++++++ .../Tests/Runtime/ImmutableAudienceTests.cs | 84 +++++++++++++++++++ 7 files changed, 282 insertions(+), 28 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 3e4a9c43..52eee53d 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -54,6 +54,7 @@ internal static class MessageFields internal const string UserId = "userId"; internal const string DeviceId = "deviceId"; internal const string ConsentLevel = "consentLevel"; + internal const string SessionId = "sessionId"; } /// diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index 83211218..7d2eb30b 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -9,7 +9,7 @@ namespace Immutable.Audience // Fires a session event (session_start / session_heartbeat / session_end) // through ImmutableAudience.Track. Declared as a named delegate so Session // can be driven by tests with a mock without touching the static SDK surface. - internal delegate void TrackDelegate(string eventName, Dictionary properties); + internal delegate void TrackDelegate(string eventName, Dictionary properties, string sessionId); // Unity session lifecycle. Emits session_start / session_heartbeat / session_end. // duration is engagement time (excludes pause). The heartbeat runs on a @@ -98,7 +98,7 @@ internal void Start() SafeTrack("session_start", new Dictionary { ["session_id"] = sessionId - }); + }, sessionId); } // Pause on focus-loss. Quiesces heartbeat; 30s threshold evaluated on next Resume. @@ -175,7 +175,7 @@ internal void End() { ["session_id"] = sessionId, ["duration_sec"] = duration - }); + }, sessionId); } // Emits session_end and seals the session without draining the heartbeat @@ -196,11 +196,14 @@ internal void EmitEndAndSeal() ResetSessionStateLocked(); } + // sessionId is the captured local, not the live SessionId property + // (already null here via ResetSessionStateLocked): the envelope must + // carry the ending session's id, not the post-reset null. SafeTrack("session_end", new Dictionary { ["session_id"] = sessionId, ["duration_sec"] = duration - }); + }, sessionId); } public void Dispose() @@ -244,18 +247,18 @@ internal void OnHeartbeat() ["duration_sec"] = duration }; - SafeTrack("session_heartbeat", properties); + SafeTrack("session_heartbeat", properties, sessionId); } // Stops exceptions from the track callback from reaching upstream. // Heartbeat runs on a background timer, where an uncaught exception // crashes the game on modern .NET. Start / End run on the caller's // thread, where it would bubble into Init / Shutdown. - private void SafeTrack(string eventName, Dictionary properties) + private void SafeTrack(string eventName, Dictionary properties, string sessionId) { try { - _track(eventName, properties); + _track(eventName, properties, sessionId); } catch (Exception ex) { diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 5679c032..3e8b3ca1 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -15,6 +15,7 @@ internal static Dictionary Track( string packageVersion, string consentLevel, Dictionary? properties = null, + string? sessionId = null, bool testMode = false) { var msg = BuildBase(MessageTypes.Track, packageVersion, consentLevel, testMode); @@ -35,6 +36,9 @@ internal static Dictionary Track( msg["properties"] = properties; } + if (!string.IsNullOrEmpty(sessionId)) + msg[MessageFields.SessionId] = Truncate(sessionId, Constants.MaxFieldLength); + return msg; } @@ -46,6 +50,7 @@ internal static Dictionary Identify( string packageVersion, string consentLevel, Dictionary? traits = null, + string? sessionId = null, bool testMode = false) { var msg = BuildBase(MessageTypes.Identify, packageVersion, consentLevel, testMode); @@ -67,6 +72,9 @@ internal static Dictionary Identify( msg["traits"] = traits; } + if (!string.IsNullOrEmpty(sessionId)) + msg[MessageFields.SessionId] = Truncate(sessionId, Constants.MaxFieldLength); + return msg; } @@ -78,6 +86,7 @@ internal static Dictionary Alias( string? deviceId, string packageVersion, string consentLevel, + string? sessionId = null, bool testMode = false) { var msg = BuildBase(MessageTypes.Alias, packageVersion, consentLevel, testMode); @@ -89,6 +98,9 @@ internal static Dictionary Alias( if (!string.IsNullOrEmpty(deviceId)) msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength); + if (!string.IsNullOrEmpty(sessionId)) + msg[MessageFields.SessionId] = Truncate(sessionId, Constants.MaxFieldLength); + return msg; } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 26abd726..56349746 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -243,7 +243,7 @@ public static void Init(AudienceConfig config) // Session created under the lock; Start() deferred until after // release because session_start → Track takes its own locks. if (initialLevel.CanTrack()) - _session = new Session(Track); + _session = new Session(TrackFromSession); // Captured reference: a later SetConsent(None) may dispose this // Session (Start then no-ops on _disposed). Either way no duplicate @@ -350,13 +350,8 @@ public static void Track(IEvent evt) return; } - var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level); - 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, - state.Level.ToLowercaseString(), properties, config.TestMode); - EnqueueTrack(msg); + EnqueueTrackedEvent(eventName, properties, _session?.SessionId, state, config); } /// @@ -379,14 +374,38 @@ public static void Track(string eventName, Dictionary? propertie var config = _config; if (config == null) return; + EnqueueTrackedEvent(eventName, SnapshotCallerDict(properties), _session?.SessionId, state, config); + } + + // Shared tail for both Track overloads and TrackFromSession: resolves + // anonymousId/deviceId/userId, builds the wire message, and enqueues it. + private static void EnqueueTrackedEvent( + string eventName, + Dictionary? properties, + string? sessionId, + ConsentState state, + AudienceConfig config) + { var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level); 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, - state.Level.ToLowercaseString(), SnapshotCallerDict(properties), config.TestMode); + state.Level.ToLowercaseString(), properties, sessionId, config.TestMode); EnqueueTrack(msg); } + // Bound to Session's TrackDelegate. sessionId is the value Session already + // captured locally before any state reset, not read live from _session + // (End() nulls the live session id before this fires for session_end). + private static void TrackFromSession(string eventName, Dictionary properties, string sessionId) + { + var state = _state; + if (!_initialized || !state.Level.CanTrack()) return; + var config = _config; + if (config == null) return; + EnqueueTrackedEvent(eventName, properties, sessionId, state, config); + } + // ----------------------------------------------------------------- // Identity // ----------------------------------------------------------------- @@ -430,7 +449,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, level.ToLowercaseString(), SnapshotCallerDict(traits), config.TestMode); + Constants.LibraryVersion, level.ToLowercaseString(), SnapshotCallerDict(traits), _session?.SessionId, config.TestMode); EnqueueIdentity(msg); } @@ -462,7 +481,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, state.Level.ToLowercaseString(), config.TestMode); + deviceId, Constants.LibraryVersion, state.Level.ToLowercaseString(), _session?.SessionId, config.TestMode); EnqueueIdentity(msg); } @@ -493,7 +512,7 @@ public static void Reset() // Swap under the lock so racing SetConsent/OnPause/OnResume see // either the old, the new, or null; never a torn reference. - _session = _state.Level.CanTrack() ? new Session(Track) : null; + _session = _state.Level.CanTrack() ? new Session(TrackFromSession) : null; newSession = _session; } @@ -655,7 +674,7 @@ public static void SetConsent(ConsentLevel level) // Upgrade from None: allocate + publish the new Session under // the lock so a concurrent SetConsent / Init sees the new // reference and the double-allocation guard above fires. - newSession = new Session(Track); + newSession = new Session(TrackFromSession); _session = newSession; } } @@ -1408,8 +1427,8 @@ private static void FireGameLaunch( } } - // No sessionId on game_launch per Event Reference. Pipeline correlates - // via eventTimestamp with the session_start that fires just before. + // game_launch has no session_id in properties (unlike the three lifecycle + // events); it still gets the envelope-level sessionId like every other event. Track("game_launch", properties.Count > 0 ? properties : null); } diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs index f9f1391b..a3f948a2 100644 --- a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs @@ -18,7 +18,7 @@ public void SetUp() _events = new List<(string, Dictionary)>(); } - private void MockTrack(string name, Dictionary props) + private void MockTrack(string name, Dictionary props, string sessionId) { _events.Add((name, props)); } @@ -90,6 +90,72 @@ public void Dispose_CalledTwice_DoesNotFireTwice() Assert.AreEqual(count, _events.Count(e => e.name == "session_end")); } + // ----------------------------------------------------------------- + // TrackDelegate sessionId pass-through + // ----------------------------------------------------------------- + + [Test] + public void Start_PassesSessionIdToTrackDelegate() + { + string capturedSessionId = null; + void Track(string name, Dictionary props, string sessionId) + { + if (name == "session_start") capturedSessionId = sessionId; + } + + using var session = new Session(Track); + session.Start(); + + Assert.AreEqual(session.SessionId, capturedSessionId, + "session_start should pass the new session's id through the delegate"); + } + + [Test] + public void End_PassesEndingSessionIdToTrackDelegate_NotNull() + { + // End() resets the live SessionId to null (via ResetSessionStateLocked) + // before firing session_end. The delegate must still receive the + // ending session's id: it comes from the local variable captured + // before the reset, not a live re-read of (by-then-null) SessionId. + string capturedSessionId = null; + void Track(string name, Dictionary props, string sessionId) + { + if (name == "session_end") capturedSessionId = sessionId; + } + + using var session = new Session(Track); + session.Start(); + var endingId = session.SessionId; + + session.End(); + + Assert.IsNotNull(capturedSessionId, + "session_end must pass the ending session's id through the delegate, not null"); + Assert.AreEqual(endingId, capturedSessionId); + } + + [Test] + public void EmitEndAndSeal_PassesEndingSessionIdToTrackDelegate_NotNull() + { + // Same race as End(): EmitEndAndSeal also resets state before + // firing session_end. + string capturedSessionId = null; + void Track(string name, Dictionary props, string sessionId) + { + if (name == "session_end") capturedSessionId = sessionId; + } + + using var session = new Session(Track); + session.Start(); + var endingId = session.SessionId; + + session.EmitEndAndSeal(); + + Assert.IsNotNull(capturedSessionId, + "EmitEndAndSeal must pass the ending session's id through the delegate, not null"); + Assert.AreEqual(endingId, capturedSessionId); + } + // ----------------------------------------------------------------- // Heartbeat // ----------------------------------------------------------------- @@ -105,7 +171,7 @@ public void Heartbeat_FiresAfterInterval() var events = new List<(string name, Dictionary props)>(); var gate = new object(); - void Track(string name, Dictionary props) + void Track(string name, Dictionary props, string sessionId) { lock (gate) events.Add((name, props)); if (name == "session_heartbeat") heartbeatFired.Set(); @@ -479,7 +545,7 @@ public void End_HeartbeatExceedsDrainBudget_LogsWarningAndContinues() using var releaseBeat = new ManualResetEvent(false); try { - void Track(string name, Dictionary props) + void Track(string name, Dictionary props, string sessionId) { if (name == "session_heartbeat") { @@ -547,7 +613,7 @@ public void OnHeartbeat_TrackCallbackThrows_DoesNotEscape() Log.Writer = line => { lock (warnings) warnings.Add(line); }; try { - void ThrowingTrack(string name, Dictionary props) + void ThrowingTrack(string name, Dictionary props, string sessionId) { if (name == "session_heartbeat") throw new InvalidOperationException("track explode"); @@ -582,7 +648,7 @@ public void Start_TrackCallbackThrows_DoesNotEscape() Log.Writer = line => { lock (warnings) warnings.Add(line); }; try { - void ThrowingTrack(string name, Dictionary props) + void ThrowingTrack(string name, Dictionary props, string sessionId) { if (name == "session_start") throw new InvalidOperationException("track explode"); @@ -617,7 +683,7 @@ public void End_TrackCallbackThrows_DoesNotEscape() Log.Writer = line => { lock (warnings) warnings.Add(line); }; try { - void ThrowingTrack(string name, Dictionary props) + void ThrowingTrack(string name, Dictionary props, string sessionId) { if (name == "session_end") throw new InvalidOperationException("track explode"); @@ -653,7 +719,7 @@ public void SafeTrack_LogWriterThrows_DoesNotEscape() Log.Writer = _ => throw new InvalidOperationException("log explode"); try { - void ThrowingTrack(string name, Dictionary props) + void ThrowingTrack(string name, Dictionary props, string sessionId) { if (name == "session_heartbeat") throw new InvalidOperationException("track explode"); diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index 92473ddf..b1c61211 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -255,5 +255,74 @@ private static IEnumerable> EveryMessageType() yield return MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); yield return MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); } + + // ----------------------------------------------------------------- + // sessionId envelope field + // ----------------------------------------------------------------- + + [Test] + public void Track_SessionIdProvided_PresentInDict() + { + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent, + sessionId: "session-1"); + + Assert.IsTrue(result.ContainsKey("sessionId")); + Assert.AreEqual("session-1", result["sessionId"]); + } + + [Test] + public void Track_SessionIdNull_AbsentFromDict() + { + var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent); + + Assert.IsFalse(result.ContainsKey("sessionId")); + } + + [Test] + public void Identify_SessionIdProvided_PresentInDict() + { + var result = MessageBuilder.Identify("anon-42", "user-42", null, "steam", PackageVersion, "full", + sessionId: "session-1"); + + Assert.IsTrue(result.ContainsKey("sessionId")); + Assert.AreEqual("session-1", result["sessionId"]); + } + + [Test] + public void Identify_SessionIdNull_AbsentFromDict() + { + var result = MessageBuilder.Identify("anon-42", "user-42", null, "steam", PackageVersion, "full"); + + Assert.IsFalse(result.ContainsKey("sessionId")); + } + + [Test] + public void Alias_SessionIdProvided_PresentInDict() + { + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", null, PackageVersion, "full", + sessionId: "session-1"); + + Assert.IsTrue(result.ContainsKey("sessionId")); + Assert.AreEqual("session-1", result["sessionId"]); + } + + [Test] + public void Alias_SessionIdNull_AbsentFromDict() + { + var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", null, PackageVersion, "full"); + + Assert.IsFalse(result.ContainsKey("sessionId")); + } + + [Test] + public void Track_SessionIdLongerThan256Chars_TruncatedTo256() + { + var longSessionId = new string('s', 300); + + var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, + sessionId: longSessionId); + + Assert.AreEqual(256, ((string)result["sessionId"]).Length); + } } } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 96bd2828..13dd2696 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -752,6 +752,90 @@ public void Alias_FullConsent_WritesAliasEvent() c.Contains("\"alias\"") && c.Contains("\"steam123\""))); } + // ----------------------------------------------------------------- + // sessionId envelope field + // ----------------------------------------------------------------- + + [Test] + public void Track_ActiveSession_EnvelopeIncludesSessionId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + var sessionId = ImmutableAudience.SessionId; + Assert.IsFalse(string.IsNullOrEmpty(sessionId), "sanity: a session should be active after Init"); + + ImmutableAudience.Track("crafting_started"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var contents = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + Assert.IsTrue( + contents.Any(c => c.Contains("\"crafting_started\"") && c.Contains($"\"sessionId\":\"{sessionId}\"")), + "a Track() envelope should carry the active session's id even though the call site never set it"); + + ImmutableAudience.Shutdown(); + } + + [Test] + public void Identify_ActiveSession_EnvelopeIncludesSessionId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + var sessionId = ImmutableAudience.SessionId; + Assert.IsFalse(string.IsNullOrEmpty(sessionId), "sanity: a session should be active after Init"); + + ImmutableAudience.Identify("player-42", IdentityType.Custom); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var contents = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + Assert.IsTrue( + contents.Any(c => c.Contains("\"identify\"") && c.Contains($"\"sessionId\":\"{sessionId}\"")), + "an Identify() envelope should carry the active session's id"); + + ImmutableAudience.Shutdown(); + } + + [Test] + public void Alias_ActiveSession_EnvelopeIncludesSessionId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + var sessionId = ImmutableAudience.SessionId; + Assert.IsFalse(string.IsNullOrEmpty(sessionId), "sanity: a session should be active after Init"); + + ImmutableAudience.Alias("steam123", IdentityType.Steam, "user_456", IdentityType.Passport); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var contents = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + Assert.IsTrue( + contents.Any(c => c.Contains("\"alias\"") && c.Contains($"\"sessionId\":\"{sessionId}\"")), + "an Alias() envelope should carry the active session's id"); + + ImmutableAudience.Shutdown(); + } + + [Test] + public void Shutdown_SessionEndEnvelope_CarriesEndingSessionId_NotNull() + { + // Regression guard: Session.End() resets its live SessionId to null + // before firing session_end, so the envelope sessionId must come + // from the value TrackDelegate was already handed, not a live + // re-read of the (by-then-null) session. If a future refactor + // reads _session?.SessionId at track-time instead, this test + // catches the resulting null envelope field. + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + var endingSessionId = ImmutableAudience.SessionId; + Assert.IsFalse(string.IsNullOrEmpty(endingSessionId), "sanity: a session should be active after Init"); + + ImmutableAudience.Shutdown(); + + var queueDir = AudiencePaths.QueueDir(_testDir); + var contents = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var sessionEnd = contents.FirstOrDefault(c => c.Contains("\"session_end\"")); + Assert.IsNotNull(sessionEnd, "Shutdown should emit session_end"); + Assert.IsTrue(sessionEnd.Contains($"\"sessionId\":\"{endingSessionId}\""), + "session_end's envelope sessionId must equal the id of the session that just ended, not be null"); + } + // ----------------------------------------------------------------- // Reset // -----------------------------------------------------------------