feat(audience): ImmutableAudience singleton + GDPR (SDK-147)#694
Merged
Conversation
3da9923 to
a8815e6
Compare
cf64b56 to
1720e85
Compare
a8815e6 to
5aefd17
Compare
1720e85 to
2cbe87f
Compare
5aefd17 to
f9b8af1
Compare
2cbe87f to
6fecbed
Compare
f9b8af1 to
f8279bd
Compare
fe69470 to
fd34007
Compare
e11b0fe to
9dd780b
Compare
fd34007 to
2268fba
Compare
Adds the ImmutableAudience static entry point that ties DiskStore, EventQueue, HttpTransport, ConsentStore, and Identity together into the public SDK surface for track/identify/alias plus the GDPR lifecycle (SetConsent, Reset, DeleteData). Public API: Init, Track (IEvent + string overloads), Identify, Alias, Reset, DeleteData, SetConsent, FlushAsync, Shutdown. Behavioural guarantees: - Concurrency: all public methods are thread-safe. Track is the allocation-light hot path - it enqueues a dictionary and returns; serialisation runs on the drain thread. - Consent state machine: None drops everything; Anonymous discards userId from track messages and drops identify/alias; Full sends everything. Downgrades rewrite the on-disk queue. - Never-throw: public methods catch and route every exception to the OnError callback, including background drain failures and shutdown flush errors. - Shutdown hygiene: final synchronous drain, drain-thread join with timeout, HttpTransport disposal, idempotent (safe to call from application exit + Unity quit). - Persistence round-trip: identity, consent, and queued events all survive process restart via atomic write-temp-then-move. Code changes: - ImmutableAudience.cs: 549-line rewrite of the stub (Init wiring, public API surface, consent state machine, backend sync loop). - AudienceConfig.cs: adds OnError, PersistentDataPath, PackageVersion, ShutdownFlushTimeoutMs, HttpHandler (test seam). - ConsentLevel.cs: ConsentLevelExtensions.ToWireString for the tracking-consent backend audit payload. - Transport/EventQueue.cs: swaps ConcurrentQueue<string> for ConcurrentQueue<Dictionary<string,object>> so Json.Serialize runs on the drain thread; adds _drainLock to serialise drain against PurgeAll / ApplyAnonymousDowngrade so a TryDequeue'd event cannot hit disk after consent revocation wiped it; EnqueueChecked closes the same window for new Track calls that race SetConsent(None). See _drainLock comment for full race. - Transport/DiskStore.cs: DeleteAll and ApplyAnonymousDowngrade for the queued-event side of consent revocation. The latter strips userId from queued track messages via JsonReader -> Json.Serialize round-trip (numeric-precision caveat documented in-file - exact for realistic Purchase.Value amounts). Tests: - ImmutableAudienceTests.cs: 737 lines covering Init/Shutdown lifecycle, the Track/Identify/Alias/Reset/DeleteData/FlushAsync public surface, the consent state machine transitions, the never-throw contract, and the privacy invariants. - ConsentSyncTests.cs: backend consent audit round-trip. - DeleteDataTests.cs: queue-purge + backend-call + Identity cache clearing interaction. - Transport/DiskStoreTests.cs, Transport/EventQueueTests.cs: coverage for the new GDPR ops. The preceding four peels (remove duplicate IdentityTests, mirror Runtime layout onto Tests, SSOT constants + scaffolding, ConsentStore) were carved out of this commit to keep the singleton diff focused on the parts that only make sense together.
- AudienceConfig.cs: PublishableKey, DistributionPlatform, OnError, PersistentDataPath, HttpHandler → nullable. PackageVersion stays non-null (has default). Keep PublishableKey nullable at the property since Init throws the actual guard. - ConsentLevel.cs: directive only (no annotations needed). - Transport/EventQueue.cs: Enqueue / EnqueueChecked msg param → nullable (existing guard `if (_disposed || msg == null) return`); stillAllowed param on EnqueueChecked → Func<bool>?. - Transport/DiskStore.cs: TryReadMessage out param → [NotNullWhen(true)] out Dictionary<string, object>? msg so callers flow-narrow to non-null on the true branch. - ImmutableAudience.cs: static reference fields (_config, _store, _queue, _transport, _controlClient, _sendTimer, _userId, DefaultPersistentDataPathProvider) → nullable. Public API default params (traits, properties, userId for DeleteData) → nullable. Identify/Alias string-overload identityType / fromType / toType → nullable (body already validates and drops on null). NotifyErrorCallback onError param → nullable. SnapshotCallerDict and Enqueue internal params → nullable. One `!` used at ConsentStore.Save call because Init's guard is out of the compiler's flow-analysis reach. Compile-time annotations only; zero runtime behaviour change.
9dd780b to
c8a7a52
Compare
…orizedAccessException ConsentStore.Save's failure surface is filesystem-bound (IOException on disk errors / retry exhaustion, UnauthorizedAccessException on permission denial). Catching bare Exception hid genuine bugs (bad config producing malformed paths, null references) behind a "persist failed" error code. The `when` filter preserves the never-throw contract for the real failure modes while letting programmer errors surface.
…ntrol-plane calls DeleteData and SyncConsentToBackend ran HTTP requests through _controlClient without a Timeout or CancellationToken, so a hung backend could pin a Task.Run indefinitely and Shutdown would dispose the HttpClient while a request was still in flight. - Sets _controlClient.Timeout to 30s so a stalled PUT/DELETE gives up rather than living forever. - Adds a static _shutdownCancellationSource, initialised in Init and cancelled + disposed in Shutdown, threaded through SendAsync on both control-plane sites. - Adds a `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)` filter that swallows the shutdown-triggered cancel silently while letting HttpClient timeout errors (TaskCanceledException without cancellationToken.IsCancellationRequested) fall through to the general Exception branch and surface as NetworkError / ConsentSyncFailed.
Moves the 30s HTTP client timeout from a literal in Init into Constants.ControlPlaneRequestTimeoutSeconds, matching the pattern used by DefaultFlushIntervalSeconds, DefaultFlushSize, etc.
The bare catch is intentional — studios' OnError delegates are caller-supplied code that can throw anything — but the blank block reads as careless. One-line comment preserves the intent for the next reader.
nattb8
reviewed
Apr 22, 2026
nattb8
reviewed
Apr 22, 2026
nattb8
reviewed
Apr 22, 2026
nattb8
reviewed
Apr 22, 2026
nattb8
reviewed
Apr 22, 2026
Add XML-style comments to both Track overloads explaining when to use each. Predefined event names should use the typed IEvent overload so required fields and value types are enforced at compile time; the string overload stays available for truly custom events but cannot catch shape drift that would break attribution reporting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57a41d9 to
69440fe
Compare
Reset now purges both in-memory and on-disk queues in addition to forgetting the current userId and rotating the anonymous id. The previous behaviour retained queued events under the prior identity, which diverged from the @imtbl/audience Web SDK's reset(), where queued events are cleared. Callers that need the prior user's events to ship before the id rotates can still await FlushAsync() first — documented inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ates Matches the @imtbl/audience Web SDK's identify(traits) shape so cross- platform studios can update profile attributes against the current anonymous user without supplying a user id. Emits an identify message with anonymousId + traits, no userId or identityType. Full consent is required, same as the named overload. Does not mutate the currently-known user id — a subsequent Track() still carries whatever id a prior Identify(userId, ...) set. Tests cover Full/Anonymous/None consent, not-initialised, null-traits drop, and that a prior userId survives the call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
System.Threading.Timer does not serialise callbacks — with a 5s flush interval and HTTP requests that can run up to the 30s timeout, a slow network stacks six overlapping callbacks, each on its own ThreadPool thread blocked inside SendBatchAsync. Gate SendBatch with Interlocked.CompareExchange so subsequent ticks return in microseconds while a send is in flight. The guard is cleared in finally so a throw inside GetResult or RescheduleSendTimer still releases the next tick, and defensively in Shutdown so a WaitOne that times out on a still-running callback cannot strand the flag at 1 across an Init/Shutdown cycle. Exposes SendBatch via an internal testing hook so the guard can be exercised without a real timer; the new test blocks the HTTP handler, fires an overlapping tick, and asserts only one request reaches the wire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
69440fe to
bb3d45f
Compare
nattb8
reviewed
Apr 22, 2026
|
|
||
| // Attach a known user id to subsequent events. String overload for | ||
| // providers not in IdentityType. | ||
| public static void Identify(string userId, string? identityType, Dictionary<string, object>? traits = null) |
Collaborator
There was a problem hiding this comment.
For CDP, can we make identityType mandatory please. For alias as well.
nattb8
approved these changes
Apr 22, 2026
nattb8
approved these changes
Apr 22, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
ImmutableAudiencestatic entry point.DiskStore,EventQueue,HttpTransport,ConsentStore, andIdentityinto the Init / Track / Identify / Alias / Reset / DeleteData / SetConsent / FlushAsync / Shutdown surface.PurgeAllandApplyAnonymousDowngradeviaEventQueue._drainLock.Nonediscards all events,Anonymousdrops identify/alias and stripsuserIdfrom track,Fullships everything.OnErrorcallback.Shutdown.Linear:
→None,Full→Anonymousdowngrade, fire-and-forget PUT to/v1/audience/tracking-consent