-
Notifications
You must be signed in to change notification settings - Fork 17
feat(audience-session): session lifecycle (SDK-145) #699
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
cf39934
feat(audience-session): session lifecycle (SDK-145)
ImmutableJeffrey 582b05e
refactor(audience): tighten comments
ImmutableJeffrey 8789399
refactor(audience-session): trackDelegate named delegate
ImmutableJeffrey 91ed73d
fix(audience-session): rename wire field duration → durationSec
ImmutableJeffrey e5c8d0a
docs(audience-session): document Start serialisation contract
ImmutableJeffrey 2043d90
docs(audience-session): correct Pause double-call rationale
ImmutableJeffrey 06f976e
fix(audience-session): drop ex.Message from SafeTrack warnings
ImmutableJeffrey 1320a1b
fix(audience-session): log.Debug breadcrumb on double-Pause
ImmutableJeffrey c0f2527
fix(audience-session): reset starts a new session
ImmutableJeffrey 768ab76
test(audience-session): reset starts a new session, does not emit ses…
ImmutableJeffrey 30a2d2c
docs(audience-session): plain-language comment pass
ImmutableJeffrey 859cdbf
fix(audience-session): drop nullable annotations on concurrent-race l…
ImmutableJeffrey c8f0173
refactor(audience-session): release _initLock before blocking teardown
ImmutableJeffrey ef58761
refactor(audience-session): apply two-phase lock to SetConsent and Re…
ImmutableJeffrey f8855b1
test(audience-session): regression test proving _initLock is released…
ImmutableJeffrey 0e30a24
fix(audience-session): emit session_end before flipping _initialized …
ImmutableJeffrey 1812e0d
refactor(audience-session): call EmitEndAndSeal before _initLock, not…
ImmutableJeffrey d4fd6e7
refactor(audience-session): hoist Identity out of _initLock; double-s…
ImmutableJeffrey 95b42b0
fix(audience-session): clamp livePause in ComputeEngagedSecondsLocked
ImmutableJeffrey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,365 @@ | ||
| #nullable enable | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Threading; | ||
|
|
||
| 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); | ||
|
|
||
| // Unity session lifecycle. Emits session_start / session_heartbeat / session_end. | ||
| // duration is engagement time (excludes pause). The heartbeat runs on a | ||
| // background thread; other methods run on the thread that called them. The | ||
| // track callback is invoked with the internal lock released. | ||
| // | ||
| // Start / End / Dispose are not safe to call from multiple threads at once. | ||
| // Callers run them one at a time (ImmutableAudience holds its init lock while | ||
| // calling Init / SetConsent / Shutdown / Reset — the only public entry points | ||
| // that touch a Session). Pause / Resume / OnHeartbeat are safe to call from | ||
| // any thread. | ||
| internal sealed class Session : IDisposable | ||
| { | ||
| internal const int HeartbeatIntervalMs = 60_000; | ||
|
|
||
| // 30s: alt-tab beyond this rolls the session on Resume. | ||
| internal const int PauseTimeoutMs = 30_000; | ||
|
|
||
| private readonly TrackDelegate _track; | ||
| private readonly Func<Dictionary<string, object>>? _performanceSnapshot; | ||
| private readonly Func<DateTime> _getUtcNow; | ||
| private readonly int _heartbeatIntervalMs; | ||
| private readonly object _lock = new object(); | ||
|
|
||
| private Timer? _heartbeatTimer; | ||
| private string? _sessionId; | ||
| private DateTime _sessionStart; | ||
| private DateTime? _pausedAt; | ||
| // Subtracted from wall-clock so duration reflects engagement. | ||
| private TimeSpan _accumulatedPause; | ||
| private bool _disposed; | ||
|
|
||
| // Current session ID. Null before Start() is called and after End()/Dispose(). | ||
| internal string? SessionId | ||
| { | ||
| get { lock (_lock) return _sessionId; } | ||
| } | ||
|
|
||
| // track: fires session events. performanceSnapshot: merges fps/memory | ||
| // into heartbeats (null on non-Unity). getUtcNow/heartbeatIntervalMs: test seams. | ||
| internal Session( | ||
| TrackDelegate track, | ||
| Func<Dictionary<string, object>>? performanceSnapshot = null, | ||
| Func<DateTime>? getUtcNow = null, | ||
| int heartbeatIntervalMs = HeartbeatIntervalMs) | ||
| { | ||
| _track = track ?? throw new ArgumentNullException(nameof(track)); | ||
| _performanceSnapshot = performanceSnapshot; | ||
| _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); | ||
| _heartbeatIntervalMs = heartbeatIntervalMs; | ||
| } | ||
|
|
||
| // Starts a session. Fires session_start and arms the heartbeat timer. | ||
| internal void Start() | ||
| { | ||
| // Phase 1: shut down the old timer with the internal lock released | ||
| // (the callback takes that lock itself). Old state left intact so a | ||
| // trailing callback sends a heartbeat for the old session — the | ||
| // backend receives it before the new session_start. | ||
| Timer? oldTimer; | ||
| lock (_lock) | ||
| { | ||
| if (_disposed) return; | ||
| oldTimer = _heartbeatTimer; | ||
| if (oldTimer != null) | ||
| { | ||
| oldTimer.Change(Timeout.Infinite, Timeout.Infinite); | ||
| _heartbeatTimer = null; | ||
| } | ||
| } | ||
|
|
||
| if (oldTimer != null) | ||
| { | ||
| using var waited = new ManualResetEvent(false); | ||
| try | ||
| { | ||
| // 500ms budget (double-Start is a misuse path). | ||
| if (oldTimer.Dispose(waited)) | ||
| waited.WaitOne(TimeSpan.FromMilliseconds(500)); | ||
| } | ||
| catch (ObjectDisposedException) | ||
| { | ||
| } | ||
| } | ||
|
|
||
| // Phase 2: populate new state. Re-check _disposed (may have flipped during drain). | ||
| string sessionId; | ||
| lock (_lock) | ||
| { | ||
| if (_disposed) return; | ||
|
|
||
| _sessionId = Guid.NewGuid().ToString(); | ||
| _sessionStart = _getUtcNow(); | ||
| _pausedAt = null; | ||
| _accumulatedPause = TimeSpan.Zero; | ||
|
|
||
| sessionId = _sessionId; | ||
| _heartbeatTimer = new Timer(_ => OnHeartbeat(), null, _heartbeatIntervalMs, _heartbeatIntervalMs); | ||
| } | ||
|
|
||
| SafeTrack("session_start", new Dictionary<string, object> | ||
| { | ||
| ["sessionId"] = sessionId | ||
| }); | ||
| } | ||
|
|
||
| // Pause on focus-loss. Quiesces heartbeat; 30s threshold evaluated on next Resume. | ||
| internal void Pause() | ||
| { | ||
| lock (_lock) | ||
| { | ||
| if (_disposed || _sessionId == null) return; | ||
| // Keep the original anchor. Shifting forward shrinks Resume's | ||
| // pauseDuration (and ComputeEngagedSecondsLocked's live pause | ||
| // when End fires while paused), over-crediting engagement. | ||
| if (_pausedAt.HasValue) | ||
| { | ||
| Log.Debug("Session: Pause while already paused — ignoring."); | ||
| return; | ||
| } | ||
| _pausedAt = _getUtcNow(); | ||
| } | ||
| } | ||
|
|
||
| // Resume on focus-gain. Pause >30s rolls the session (End + Start). | ||
| internal void Resume() | ||
| { | ||
| bool extended; | ||
| lock (_lock) | ||
| { | ||
| if (_disposed || _sessionId == null || _pausedAt == null) return; | ||
|
|
||
| var pauseDuration = _getUtcNow() - _pausedAt.Value; | ||
| _pausedAt = null; | ||
|
|
||
| // Clamp: wall-clock rewind (NTP) would otherwise over-credit engagement. | ||
| if (pauseDuration < TimeSpan.Zero) pauseDuration = TimeSpan.Zero; | ||
|
|
||
| extended = pauseDuration.TotalMilliseconds > PauseTimeoutMs; | ||
|
|
||
| // Credit in both paths. End (and then Start) reset the accumulator | ||
| // on the extended-pause rollover so there is no double-count. | ||
| _accumulatedPause += pauseDuration; | ||
| } | ||
|
|
||
| if (extended) | ||
| { | ||
| // Extended pause: roll the session. End/Start fire _track outside _lock. | ||
| // Between End and Start other public methods early-return on _sessionId=null. | ||
| End(); | ||
| Start(); | ||
| } | ||
| } | ||
|
|
||
| // Ends the session. Drains heartbeat before emitting session_end so wire | ||
| // order holds (drain timeout is best-effort; logs a warning on timeout). | ||
| internal void End() | ||
| { | ||
| // Phase 1: drain outside _lock (OnHeartbeat re-enters _lock). | ||
| DrainHeartbeatTimer(); | ||
|
|
||
| // Phase 2: capture fields and reset so subsequent Start/Dispose sees clean state. | ||
| string sessionId; | ||
| long duration; | ||
| lock (_lock) | ||
| { | ||
| if (_sessionId == null) return; | ||
| sessionId = _sessionId!; | ||
|
|
||
| // ComputeEngagedSecondsLocked folds in the live pause. | ||
| duration = ComputeEngagedSecondsLocked(); | ||
| ResetSessionStateLocked(); | ||
| } | ||
|
|
||
| // duration is engagement-aware (excludes pause). Web SDK emits | ||
| // wall-clock; dashboards should not assume parity. | ||
| SafeTrack("session_end", new Dictionary<string, object> | ||
| { | ||
| ["sessionId"] = sessionId, | ||
| ["durationSec"] = duration | ||
| }); | ||
| } | ||
|
|
||
| // Emits session_end and seals the session without draining the heartbeat | ||
| // timer. Use when the caller needs to fire session_end inside a short | ||
| // gating lock (e.g. ImmutableAudience.Shutdown under _initLock while | ||
| // _initialized is still true) and will drain + dispose the timer after | ||
| // releasing the lock. Idempotent: a subsequent Dispose() → End() will | ||
| // find _sessionId null and no-op the re-emission. | ||
| internal void EmitEndAndSeal() | ||
| { | ||
| string sessionId; | ||
| long duration; | ||
| lock (_lock) | ||
| { | ||
| if (_disposed || _sessionId == null) return; | ||
| sessionId = _sessionId!; | ||
| duration = ComputeEngagedSecondsLocked(); | ||
| ResetSessionStateLocked(); | ||
| } | ||
|
|
||
| SafeTrack("session_end", new Dictionary<string, object> | ||
| { | ||
| ["sessionId"] = sessionId, | ||
| ["durationSec"] = duration | ||
| }); | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| lock (_lock) | ||
| { | ||
| if (_disposed) return; | ||
| _disposed = true; | ||
| } | ||
|
|
||
| // End does the drain + emit. Dispose adds the _disposed latch | ||
| // which blocks subsequent Start/Pause/Resume. | ||
| End(); | ||
| } | ||
|
|
||
| // ----------------------------------------------------------------- | ||
| // Private | ||
| // ----------------------------------------------------------------- | ||
|
|
||
| // Fires a heartbeat. Internal so tests can drive without waiting 60s. | ||
| // Skips while paused so backgrounded games don't dribble heartbeats. | ||
| internal void OnHeartbeat() | ||
| { | ||
| string sessionId; | ||
| long duration; | ||
| lock (_lock) | ||
| { | ||
| if (_disposed || _sessionId == null) return; | ||
| // A paused session doesn't send heartbeats. The timer keeps | ||
| // firing internally; this check stops the event from going out. | ||
| if (_pausedAt.HasValue) return; | ||
| sessionId = _sessionId!; | ||
|
|
||
| duration = ComputeEngagedSecondsLocked(); | ||
| } | ||
|
|
||
| // Build outside _lock so snapshot + track don't re-enter. | ||
| var properties = new Dictionary<string, object> | ||
| { | ||
| ["sessionId"] = sessionId, | ||
| ["durationSec"] = duration | ||
| }; | ||
|
|
||
| var perf = SafePerformanceSnapshot(); | ||
| if (perf != null) | ||
| { | ||
| foreach (var kv in perf) | ||
| { | ||
| // Don't let the provider clobber core fields. | ||
| if (properties.ContainsKey(kv.Key)) continue; | ||
| properties[kv.Key] = kv.Value; | ||
| } | ||
| } | ||
|
|
||
| SafeTrack("session_heartbeat", properties); | ||
| } | ||
|
|
||
| // Stops exceptions from the track callback from reaching upstream. | ||
| // Heartbeat runs on a background timer — an uncaught exception there | ||
| // 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) | ||
| { | ||
| try | ||
| { | ||
| _track(eventName, properties); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Log.Warn($"Session: {eventName} track callback threw {ex.GetType().Name}. Event dropped."); | ||
| } | ||
| } | ||
|
|
||
| // Stops exceptions from the studio-supplied snapshot callback from | ||
| // reaching the background timer. | ||
| private Dictionary<string, object>? SafePerformanceSnapshot() | ||
| { | ||
| if (_performanceSnapshot == null) return null; | ||
| try | ||
| { | ||
| return _performanceSnapshot(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Log.Warn($"Session: performance snapshot threw {ex.GetType().Name}. Heartbeat ships without performance fields."); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // Stops the timer and waits for the in-flight callback. Runs outside | ||
| // _lock (OnHeartbeat re-enters). 1s budget (quits must not hang). Warns on timeout. | ||
| private void DrainHeartbeatTimer() | ||
| { | ||
| Timer? timer; | ||
| lock (_lock) | ||
| { | ||
| timer = _heartbeatTimer; | ||
| _heartbeatTimer = null; | ||
| } | ||
| if (timer == null) return; | ||
|
|
||
| using var waited = new ManualResetEvent(false); | ||
| try | ||
| { | ||
| // Timer was already disposed. The signal handle won't fire, so | ||
| // don't wait for it. | ||
| if (!timer.Dispose(waited)) | ||
| return; | ||
|
|
||
| if (!waited.WaitOne(TimeSpan.FromSeconds(1))) | ||
| { | ||
| Log.Warn("Session: heartbeat callback did not complete within 1s on timer stop. " + | ||
| "A trailing session_heartbeat may race with the next session lifecycle event."); | ||
| } | ||
| } | ||
| catch (ObjectDisposedException) | ||
| { | ||
| } | ||
| } | ||
|
|
||
| // Caller must hold _lock. Engagement seconds = wall-clock − accumulated − live pause. | ||
| // Rounded to match Web SDK's Math.round. Clamped ≥0 for clock rewinds. | ||
| private long ComputeEngagedSecondsLocked() | ||
| { | ||
| var now = _getUtcNow(); | ||
| var livePause = _pausedAt.HasValue ? now - _pausedAt.Value : TimeSpan.Zero; | ||
| // Clamp: mirrors the Resume() guard. If the clock rewinds while the | ||
| // session is still paused and End / EmitEndAndSeal fires (e.g. | ||
| // Shutdown while backgrounded), livePause would be negative and, | ||
| // being subtracted, would inflate engagedSeconds past the wall-clock | ||
| // window. The final ≥0 clamp catches negatives but not inflation. | ||
| if (livePause < TimeSpan.Zero) livePause = TimeSpan.Zero; | ||
| var engagedSeconds = ((now - _sessionStart) - _accumulatedPause - livePause).TotalSeconds; | ||
| if (engagedSeconds < 0) return 0; | ||
| return (long)Math.Round(engagedSeconds, MidpointRounding.AwayFromZero); | ||
| } | ||
|
|
||
| // Caller must hold _lock. Clears per-session state after End. | ||
| // Start inlines equivalent assignments; new state fields must update both. | ||
| private void ResetSessionStateLocked() | ||
| { | ||
| _sessionId = null; | ||
| _pausedAt = null; | ||
| _accumulatedPause = TimeSpan.Zero; | ||
| } | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.