-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathImmutableAudience.cs
More file actions
673 lines (575 loc) · 28 KB
/
Copy pathImmutableAudience.cs
File metadata and controls
673 lines (575 loc) · 28 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
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Immutable.Audience
{
// Entry point for the Immutable Audience SDK.
public static class ImmutableAudience
{
// Reference fields are written inside _initLock; readers fence off the volatile _initialized load.
// _consent and _userId are mutated outside the lock and need volatile themselves.
private static AudienceConfig? _config;
private static DiskStore? _store;
private static EventQueue? _queue;
private static HttpTransport? _transport;
private static HttpClient? _controlClient;
private static CancellationTokenSource? _shutdownCancellationSource;
private static Timer? _sendTimer;
private static volatile ConsentLevel _consent;
private static volatile string? _userId;
private static volatile bool _initialized;
private static readonly object _initLock = new object();
// Guard against overlapping timer ticks. System.Threading.Timer fires
// callbacks on independent ThreadPool threads and does not serialise
// them; without this gate, a slow SendBatchAsync (up to the HTTP
// timeout) would stack on every interval tick, each tick holding its
// own thread blocked on a pending request.
private static int _sendInFlight;
// AudienceUnityHooks sets this at SubsystemRegistration so Unity studios
// can omit PersistentDataPath from AudienceConfig and Init will fill it
// from Application.persistentDataPath. Non-Unity callers must still set
// PersistentDataPath on the config.
internal static Func<string>? DefaultPersistentDataPathProvider;
// AudienceUnityHooks sets this so game_launch can auto-include
// Unity context without the core referencing UnityEngine.
internal static Func<Dictionary<string, object>>? LaunchContextProvider;
// Starts the SDK. Call once at launch.
public static void Init(AudienceConfig config)
{
if (config == null) throw new ArgumentNullException(nameof(config));
// Copy the caller's config so filling in PersistentDataPath below
// doesn't change the object they passed in. Validating after the
// clone keeps the compiler's null-flow analysis for PublishableKey
// alive through the HttpTransport constructor below.
config = config.Clone();
if (string.IsNullOrEmpty(config.PublishableKey))
throw new ArgumentException("PublishableKey is required", nameof(config));
if (string.IsNullOrEmpty(config.PersistentDataPath))
config.PersistentDataPath = DefaultPersistentDataPathProvider?.Invoke();
if (string.IsNullOrEmpty(config.PersistentDataPath))
throw new ArgumentException("PersistentDataPath is required", nameof(config));
ConsentLevel consentAtInit;
lock (_initLock)
{
if (_initialized)
{
Log.Warn("Init called more than once — ignoring; original config retained. " +
"Call Shutdown() first if reconfiguring is intended.");
return;
}
_config = config;
Log.Enabled = config.Debug;
// Persisted consent overrides the config default so a prior runtime downgrade survives restart.
_consent = ConsentStore.Load(config.PersistentDataPath) ?? config.Consent;
_store = new DiskStore(config.PersistentDataPath);
_queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize);
_transport = new HttpTransport(_store, config.PublishableKey, config.OnError, config.HttpHandler);
_controlClient = config.HttpHandler != null
? new HttpClient(config.HttpHandler, disposeHandler: false)
: new HttpClient();
_controlClient.Timeout = TimeSpan.FromSeconds(Constants.ControlPlaneRequestTimeoutSeconds);
_shutdownCancellationSource = new CancellationTokenSource();
// Disk → network timer. EventQueue owns the separate memory → disk drain.
var sendIntervalMs = Math.Max(1, config.FlushIntervalSeconds) * 1000;
_sendTimer = new Timer(_ => SendBatch(), null, sendIntervalMs, sendIntervalMs);
_initialized = true;
// Snapshot under the lock so a racing SetConsent(None) can't drop the launch event.
consentAtInit = _consent;
}
FireGameLaunch(config, consentAtInit);
}
// -----------------------------------------------------------------
// Track
// -----------------------------------------------------------------
// Send a typed event.
//
// Prefer this overload for predefined event names (e.g. purchase) — the
// IEvent implementation enforces required fields and value types at
// compile time. The string overload accepts any property shape and
// cannot catch missing or mistyped fields.
public static void Track(IEvent evt)
{
if (!CanTrack()) return;
if (evt == null)
{
Log.Warn("Track(IEvent) called with null event — dropping.");
return;
}
var config = _config;
if (config == null) return;
// Consumer-supplied impl; catch so a buggy IEvent cannot crash the game.
string eventName;
Dictionary<string, object> properties;
try
{
eventName = evt.EventName;
properties = evt.ToProperties();
}
catch (Exception ex)
{
Log.Warn($"Track(IEvent) — {evt.GetType().Name}.ToProperties()/EventName threw {ex.GetType().Name}: {ex.Message}. Dropping.");
return;
}
if (string.IsNullOrEmpty(eventName))
{
Log.Warn($"Track(IEvent) — {evt.GetType().Name}.EventName returned null or empty. Dropping.");
return;
}
var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, _consent);
// ToProperties returns a fresh dict per call, so no snapshot needed.
var msg = MessageBuilder.Track(eventName, anonymousId, _userId, config.PackageVersion, properties);
Enqueue(msg);
}
// Send a custom event.
//
// For predefined event names (e.g. purchase, progression, resource,
// milestone_reached), prefer the typed overload —
// Track(new Purchase { Currency = "USD", Value = 9.99m }) — which
// validates required fields at send time. This overload accepts any
// property shape and does not: Track("purchase", new Dictionary...)
// that omits currency or value still enqueues and ships, but breaks
// attribution and conversion reporting downstream because the
// payload is missing the fields CDP needs to reconstruct the event.
public static void Track(string eventName, Dictionary<string, object>? properties = null)
{
if (!CanTrack()) return;
if (string.IsNullOrEmpty(eventName))
{
Log.Warn("Track(string) called with null or empty event name — dropping.");
return;
}
var config = _config;
if (config == null) return;
var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, _consent);
var msg = MessageBuilder.Track(eventName, anonymousId, _userId, config.PackageVersion,
SnapshotCallerDict(properties));
Enqueue(msg);
}
// -----------------------------------------------------------------
// Identity
// -----------------------------------------------------------------
// Attach a known user id to subsequent events.
public static void Identify(string userId, IdentityType identityType, Dictionary<string, object>? traits = null) =>
Identify(userId, identityType.ToLowercaseString(), traits);
// Attach a known user id to subsequent events. String overload for
// providers not in IdentityType.
//
// identityType is required: data-deletion processing relies on it to
// match identify events to the correct identity namespace, so an
// event without one cannot be cleaned up.
public static void Identify(string userId, string identityType, Dictionary<string, object>? traits = null)
{
if (!_initialized) return;
// Validate inputs before consent so null-arg callers get the right warning.
if (string.IsNullOrEmpty(userId))
{
Log.Warn("Identify called with null or empty userId — dropping.");
return;
}
if (!_consent.CanIdentify())
{
Log.Warn($"Identify discarded — requires Full consent, current is {_consent}");
return;
}
var config = _config;
if (config == null) return;
_userId = userId;
var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, _consent);
var msg = MessageBuilder.Identify(anonymousId, userId, identityType, config.PackageVersion,
SnapshotCallerDict(traits));
Enqueue(msg);
}
// Link two user ids for the same player.
public static void Alias(string fromId, IdentityType fromType, string toId, IdentityType toType) =>
Alias(fromId, fromType.ToLowercaseString(), toId, toType.ToLowercaseString());
// Link two user ids for the same player. String overload for
// providers not in IdentityType.
//
// fromType and toType are required: data-deletion processing uses
// them to match alias events to the correct identity namespaces.
public static void Alias(string fromId, string fromType, string toId, string toType)
{
if (!_initialized) return;
if (string.IsNullOrEmpty(fromId) || string.IsNullOrEmpty(toId))
{
Log.Warn("Alias called with null or empty fromId/toId — dropping.");
return;
}
if (!_consent.CanIdentify())
{
Log.Warn($"Alias discarded — requires Full consent, current is {_consent}");
return;
}
var config = _config;
if (config == null) return;
var msg = MessageBuilder.Alias(fromId, fromType, toId, toType, config.PackageVersion);
Enqueue(msg);
}
// Log out the current player. Clears the user id, generates a fresh
// anonymous id, and discards queued events (in-memory and on-disk)
// so the next player on this device isn't attributed to the previous
// one.
//
// To send queued events before they're discarded,
// invoke await FlushAsync() first:
//
// await ImmutableAudience.FlushAsync();
// ImmutableAudience.Reset();
public static void Reset()
{
if (!_initialized) return;
var config = _config;
if (config == null) return;
_userId = null;
_queue?.PurgeAll();
Identity.Reset(config.PersistentDataPath!);
}
// Ask the backend to erase this player's data. Returns a task the
// caller can await to know when the request is acknowledged, or
// discard for fire-and-forget.
public static Task DeleteData(string? userId = null)
{
if (!_initialized) return Task.CompletedTask;
var config = _config;
var client = _controlClient;
if (config == null || client == null) return Task.CompletedTask;
string query;
if (!string.IsNullOrEmpty(userId))
{
query = "userId=" + Uri.EscapeDataString(userId);
}
else
{
// Get, not GetOrCreate — a brand-new install must not register an ID just to delete it.
var anonymousId = Identity.Get(config.PersistentDataPath!);
if (string.IsNullOrEmpty(anonymousId))
return Task.CompletedTask;
query = "anonymousId=" + Uri.EscapeDataString(anonymousId);
}
var url = Constants.DataUrl(config.PublishableKey) + "?" + query;
var onError = config.OnError;
var publishableKey = config.PublishableKey;
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
return Task.Run(async () =>
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
request.Headers.Add(Constants.PublishableKeyHeader, publishableKey);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
NotifyErrorCallback(onError, AudienceErrorCode.NetworkError,
$"Data delete failed with status {(int)response.StatusCode}");
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown cancelled the request — no error fired; caller is tearing down.
}
catch (Exception ex)
{
NotifyErrorCallback(onError, AudienceErrorCode.NetworkError,
$"Data delete threw: {ex.Message}");
}
});
}
private static void NotifyErrorCallback(Action<AudienceError>? onError, AudienceErrorCode code, string message)
{
if (onError == null) return;
try
{
onError(new AudienceError(code, message));
}
catch
{
// Swallow: a buggy OnError must not crash the SDK surface.
}
}
// -----------------------------------------------------------------
// Consent
// -----------------------------------------------------------------
// Change the player's consent level.
public static void SetConsent(ConsentLevel level)
{
if (!_initialized) return;
var config = _config;
var queue = _queue;
if (config == null) return;
var previous = _consent;
if (level == previous) return;
// Snapshot the anonymousId BEFORE Identity.Reset (on downgrade to
// None) wipes the file. The PUT audit trail needs it to record
// whose consent changed.
var anonymousIdForPut = previous == ConsentLevel.None
? Identity.GetOrCreate(config.PersistentDataPath!, level)
: Identity.Get(config.PersistentDataPath!);
_consent = level;
try
{
// PersistentDataPath is validated non-null in Init; compiler can't propagate that.
ConsentStore.Save(config.PersistentDataPath!, level);
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
Log.Warn($"SetConsent — failed to persist consent level: {ex.GetType().Name}: {ex.Message}. " +
"In-memory level is updated but will revert on next launch.");
NotifyErrorCallback(config.OnError, AudienceErrorCode.ConsentPersistFailed,
$"Consent persist failed: {ex.Message}");
}
if (level == ConsentLevel.None)
{
queue?.PurgeAll();
Identity.Reset(config.PersistentDataPath!);
}
else if (previous == ConsentLevel.Full && level == ConsentLevel.Anonymous)
{
_userId = null;
queue?.ApplyAnonymousDowngrade();
}
SyncConsentToBackend(config, level, anonymousIdForPut);
}
// Fire-and-forget PUT /v1/audience/tracking-consent. Failures do not
// block or surface; the local consent change has already applied.
private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel level, string? anonymousId)
{
var client = _controlClient;
if (client == null) return;
var url = Constants.ConsentUrl(config.PublishableKey);
var publishableKey = config.PublishableKey;
var onError = config.OnError;
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
var body = Json.Serialize(new Dictionary<string, object>
{
["status"] = level.ToLowercaseString(),
["source"] = Constants.ConsentSource,
// Json.Serialize emits null → "anonymousId": null. Preserves the backend's ability to distinguish "unknown" from a missing field.
["anonymousId"] = anonymousId!,
});
Task.Run(async () =>
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Put, url);
request.Headers.Add(Constants.PublishableKeyHeader, publishableKey);
request.Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
NotifyErrorCallback(onError, AudienceErrorCode.ConsentSyncFailed,
$"Consent sync failed with status {(int)response.StatusCode}");
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown cancelled the request — no error fired.
}
catch (Exception ex)
{
NotifyErrorCallback(onError, AudienceErrorCode.ConsentSyncFailed,
$"Consent sync threw: {ex.Message}");
}
});
}
// -----------------------------------------------------------------
// Flush / Shutdown
// -----------------------------------------------------------------
// Send pending events now.
public static async Task FlushAsync()
{
if (!_initialized) return;
var queue = _queue;
var transport = _transport;
if (queue == null || transport == null) return;
queue.FlushSync();
while (!transport.IsInBackoffWindow &&
await transport.SendBatchAsync().ConfigureAwait(false))
{
}
}
// Flush and stop the SDK.
public static void Shutdown()
{
if (!_initialized) return;
// Drain in-flight timer callbacks before disposing dependents.
// Parameterless Timer.Dispose returns immediately and would race SendBatch.
var timer = _sendTimer;
if (timer != null)
{
using var disposed = new ManualResetEvent(false);
if (timer.Dispose(disposed))
{
disposed.WaitOne(TimeSpan.FromSeconds(2));
}
_sendTimer = null;
}
// Clear the in-flight guard in case the WaitOne above timed out
// with a SendBatch callback still running: without this, a later
// Init would leave _sendInFlight stranded at 1 and suppress every
// tick of the new timer.
Interlocked.Exchange(ref _sendInFlight, 0);
_queue?.Shutdown();
// Best-effort final send, capped so a slow network can't hang quit.
if (_transport != null)
{
var timeoutMs = _config?.ShutdownFlushTimeoutMs ?? 2_000;
try
{
var send = _transport.SendBatchAsync();
if (!send.Wait(timeoutMs))
{
Log.Warn($"Shutdown flush exceeded {timeoutMs}ms — abandoning. " +
"Queued events remain on disk and will retry on next startup.");
}
}
catch (Exception ex)
{
Log.Warn($"Shutdown flush threw: {ex.GetType().Name}: {ex.Message}");
}
}
// Cancel in-flight control-plane HTTP requests (DeleteData / SyncConsentToBackend)
// before disposing the client so awaiting callers observe OperationCanceledException
// rather than ObjectDisposedException.
_shutdownCancellationSource?.Cancel();
_transport?.Dispose();
_queue?.Dispose();
_controlClient?.Dispose();
_shutdownCancellationSource?.Dispose();
_shutdownCancellationSource = null;
// Drop Identity's in-memory cache so a subsequent Init with a
// different persistentDataPath reads the file from the new path
// instead of returning the previous session's id.
Identity.ClearCache();
_initialized = false;
_config = null;
_store = null;
_queue = null;
_transport = null;
_controlClient = null;
_userId = null;
}
// -----------------------------------------------------------------
// Internal — shared with tests and AudienceUnityHooks
// -----------------------------------------------------------------
// Shuts down (if initialised) and clears per-session state so a
// fresh Init starts clean. Used on test teardown and by Unity
// SubsystemRegistration to survive "disable domain reload".
// LaunchContextProvider is not cleared: AudienceUnityHooks
// re-assigns it on the same SubsystemRegistration call.
internal static void ResetState()
{
if (_initialized)
Shutdown();
_consent = ConsentLevel.None;
// Drop Identity's static cache so a subsequent Init with a different
// persistentDataPath (tests, domain reload with changed config) reads
// the file from the new path, not the previous session's cached id.
Identity.ClearCache();
}
internal static ConsentLevel CurrentConsentForTesting => _consent;
internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync();
// Invokes the timer callback body directly so the overlapping-tick
// guard can be exercised without a real timer.
internal static void SendBatchForTesting() => SendBatch();
// -----------------------------------------------------------------
// Private
// -----------------------------------------------------------------
private static bool CanTrack()
{
return _initialized && _consent.CanTrack();
}
// Shallow-copy the caller's dict so a post-call mutation cannot race the drain-thread serialiser.
private static Dictionary<string, object>? SnapshotCallerDict(Dictionary<string, object>? src) =>
src != null ? new Dictionary<string, object>(src) : null;
private static void Enqueue(Dictionary<string, object>? msg)
{
var queue = _queue;
if (queue == null) return;
// Re-check consent inside the drain lock so a SetConsent(None) racing
// the caller's CanTrack cannot leak this event past the purge.
queue.EnqueueChecked(msg, () => _consent.CanTrack());
}
private static void SendBatch()
{
// CAS in the guard before doing any work; a previous tick still
// running means skip entirely, including the reschedule — the
// in-flight tick will reschedule on its own finally path.
if (Interlocked.CompareExchange(ref _sendInFlight, 1, 0) != 0)
return;
try
{
var transport = _transport;
if (transport == null) return;
if (!transport.IsInBackoffWindow)
{
try
{
transport.SendBatchAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// ThreadPool timer thread; no caller above to catch.
Log.Warn($"SendBatch unexpected exception: {ex.GetType().Name}: {ex.Message}");
}
}
RescheduleSendTimer(transport);
}
finally
{
Interlocked.Exchange(ref _sendInFlight, 0);
}
}
// Realigns the timer to NextAttemptAt so we don't repoll through a long backoff window.
private static void RescheduleSendTimer(HttpTransport transport)
{
var timer = _sendTimer;
var config = _config;
if (timer == null || config == null || transport == null) return;
var sendIntervalMs = Math.Max(1, config.FlushIntervalSeconds) * 1000;
var nextMs = sendIntervalMs;
if (transport.NextAttemptAt is DateTime scheduled)
{
var delayMs = (scheduled - DateTime.UtcNow).TotalMilliseconds;
if (delayMs > sendIntervalMs)
nextMs = (int)Math.Min(int.MaxValue, delayMs);
}
timer.Change(nextMs, sendIntervalMs);
}
// consentAtInit snapshot is only used to skip the launch event under None;
// Track still consults live _consent via CanTrack, so a SetConsent(None)
// landing between Init returning and here still drops the event.
private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit)
{
if (!consentAtInit.CanTrack()) return;
var properties = new Dictionary<string, object>();
// Unity-side auto-detected context (platform, version, buildGuid,
// unityVersion) from AudienceUnityHooks. Core stays pure C#; the
// Unity layer fills these via LaunchContextProvider.
var provider = LaunchContextProvider;
if (provider != null)
{
Dictionary<string, object>? unityContext = null;
try { unityContext = provider(); }
catch (Exception ex)
{
Log.Warn($"LaunchContextProvider threw {ex.GetType().Name}: {ex.Message}. " +
"game_launch will ship without auto-detected Unity context.");
}
if (unityContext != null)
{
foreach (var kvp in unityContext)
properties[kvp.Key] = kvp.Value;
}
}
// Config-supplied distributionPlatform wins over any provider value;
// studios set it explicitly because Unity cannot auto-detect the store.
if (config.DistributionPlatform != null)
properties["distributionPlatform"] = config.DistributionPlatform;
Track("game_launch", properties.Count > 0 ? properties : null);
}
}
}