Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/// <summary>
Expand Down
17 changes: 10 additions & 7 deletions src/Packages/Audience/Runtime/Core/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> properties);
internal delegate void TrackDelegate(string eventName, Dictionary<string, object> properties, string sessionId);

// Unity session lifecycle. Emits session_start / session_heartbeat / session_end.
// duration is engagement time (excludes pause). The heartbeat runs on a
Expand Down Expand Up @@ -98,7 +98,7 @@ internal void Start()
SafeTrack("session_start", new Dictionary<string, object>
{
["session_id"] = sessionId
});
}, sessionId);
}

// Pause on focus-loss. Quiesces heartbeat; 30s threshold evaluated on next Resume.
Expand Down Expand Up @@ -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
Expand All @@ -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<string, object>
{
["session_id"] = sessionId,
["duration_sec"] = duration
});
}, sessionId);
}

public void Dispose()
Expand Down Expand Up @@ -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<string, object> properties)
private void SafeTrack(string eventName, Dictionary<string, object> properties, string sessionId)
{
try
{
_track(eventName, properties);
_track(eventName, properties, sessionId);
}
catch (Exception ex)
{
Expand Down
12 changes: 12 additions & 0 deletions src/Packages/Audience/Runtime/Events/MessageBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal static Dictionary<string, object> Track(
string packageVersion,
string consentLevel,
Dictionary<string, object>? properties = null,
string? sessionId = null,
bool testMode = false)
{
var msg = BuildBase(MessageTypes.Track, packageVersion, consentLevel, testMode);
Expand All @@ -35,6 +36,9 @@ internal static Dictionary<string, object> Track(
msg["properties"] = properties;
}

if (!string.IsNullOrEmpty(sessionId))
msg[MessageFields.SessionId] = Truncate(sessionId, Constants.MaxFieldLength);

return msg;
}

Expand All @@ -46,6 +50,7 @@ internal static Dictionary<string, object> Identify(
string packageVersion,
string consentLevel,
Dictionary<string, object>? traits = null,
string? sessionId = null,
bool testMode = false)
{
var msg = BuildBase(MessageTypes.Identify, packageVersion, consentLevel, testMode);
Expand All @@ -67,6 +72,9 @@ internal static Dictionary<string, object> Identify(
msg["traits"] = traits;
}

if (!string.IsNullOrEmpty(sessionId))
msg[MessageFields.SessionId] = Truncate(sessionId, Constants.MaxFieldLength);

return msg;
}

Expand All @@ -78,6 +86,7 @@ internal static Dictionary<string, object> Alias(
string? deviceId,
string packageVersion,
string consentLevel,
string? sessionId = null,
bool testMode = false)
{
var msg = BuildBase(MessageTypes.Alias, packageVersion, consentLevel, testMode);
Expand All @@ -89,6 +98,9 @@ internal static Dictionary<string, object> 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;
}

Expand Down
47 changes: 33 additions & 14 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand All @@ -379,14 +374,38 @@ public static void Track(string eventName, Dictionary<string, object>? 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<string, object>? 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<string, object> 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
// -----------------------------------------------------------------
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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);
}

Expand Down
80 changes: 73 additions & 7 deletions src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public void SetUp()
_events = new List<(string, Dictionary<string, object>)>();
}

private void MockTrack(string name, Dictionary<string, object> props)
private void MockTrack(string name, Dictionary<string, object> props, string sessionId)
{
_events.Add((name, props));
}
Expand Down Expand Up @@ -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<string, object> 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<string, object> 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<string, object> 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
// -----------------------------------------------------------------
Expand All @@ -105,7 +171,7 @@ public void Heartbeat_FiresAfterInterval()
var events = new List<(string name, Dictionary<string, object> props)>();
var gate = new object();

void Track(string name, Dictionary<string, object> props)
void Track(string name, Dictionary<string, object> props, string sessionId)
{
lock (gate) events.Add((name, props));
if (name == "session_heartbeat") heartbeatFired.Set();
Expand Down Expand Up @@ -479,7 +545,7 @@ public void End_HeartbeatExceedsDrainBudget_LogsWarningAndContinues()
using var releaseBeat = new ManualResetEvent(false);
try
{
void Track(string name, Dictionary<string, object> props)
void Track(string name, Dictionary<string, object> props, string sessionId)
{
if (name == "session_heartbeat")
{
Expand Down Expand Up @@ -547,7 +613,7 @@ public void OnHeartbeat_TrackCallbackThrows_DoesNotEscape()
Log.Writer = line => { lock (warnings) warnings.Add(line); };
try
{
void ThrowingTrack(string name, Dictionary<string, object> props)
void ThrowingTrack(string name, Dictionary<string, object> props, string sessionId)
{
if (name == "session_heartbeat")
throw new InvalidOperationException("track explode");
Expand Down Expand Up @@ -582,7 +648,7 @@ public void Start_TrackCallbackThrows_DoesNotEscape()
Log.Writer = line => { lock (warnings) warnings.Add(line); };
try
{
void ThrowingTrack(string name, Dictionary<string, object> props)
void ThrowingTrack(string name, Dictionary<string, object> props, string sessionId)
{
if (name == "session_start")
throw new InvalidOperationException("track explode");
Expand Down Expand Up @@ -617,7 +683,7 @@ public void End_TrackCallbackThrows_DoesNotEscape()
Log.Writer = line => { lock (warnings) warnings.Add(line); };
try
{
void ThrowingTrack(string name, Dictionary<string, object> props)
void ThrowingTrack(string name, Dictionary<string, object> props, string sessionId)
{
if (name == "session_end")
throw new InvalidOperationException("track explode");
Expand Down Expand Up @@ -653,7 +719,7 @@ public void SafeTrack_LogWriterThrows_DoesNotEscape()
Log.Writer = _ => throw new InvalidOperationException("log explode");
try
{
void ThrowingTrack(string name, Dictionary<string, object> props)
void ThrowingTrack(string name, Dictionary<string, object> props, string sessionId)
{
if (name == "session_heartbeat")
throw new InvalidOperationException("track explode");
Expand Down
Loading
Loading