-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathSession.cs
More file actions
310 lines (271 loc) · 12 KB
/
Copy pathSession.cs
File metadata and controls
310 lines (271 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
#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<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. getUtcNow/heartbeatIntervalMs: test seams.
internal Session(
TrackDelegate track,
Func<DateTime>? getUtcNow = null,
int heartbeatIntervalMs = HeartbeatIntervalMs)
{
_track = track ?? throw new ArgumentNullException(nameof(track));
_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, and 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;
}
}
// 500ms budget. Double-Start is a misuse path.
TimerDisposal.DisposeAndWait(oldTimer, TimeSpan.FromMilliseconds(500));
// 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(AudienceLogs.SessionPauseAlreadyPaused);
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 track doesn't re-enter.
var properties = new Dictionary<string, object>
{
["sessionId"] = sessionId,
["durationSec"] = duration
};
SafeTrack("session_heartbeat", properties);
}
// 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)
{
try
{
_track(eventName, properties);
}
catch (Exception ex)
{
Log.Warn(AudienceLogs.SessionTrackCallbackThrew(eventName, ex));
}
}
// 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;
if (!TimerDisposal.DisposeAndWait(timer, TimeSpan.FromSeconds(1)))
{
Log.Warn(AudienceLogs.SessionHeartbeatTimeout);
}
}
// 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;
}
}
}