Skip to content

feat(audience): ImmutableAudience singleton + GDPR (SDK-147)#694

Merged
ImmutableJeffrey merged 10 commits into
mainfrom
feat/sdk-147-singleton-3
Apr 22, 2026
Merged

feat(audience): ImmutableAudience singleton + GDPR (SDK-147)#694
ImmutableJeffrey merged 10 commits into
mainfrom
feat/sdk-147-singleton-3

Conversation

@ImmutableJeffrey
Copy link
Copy Markdown
Collaborator

@ImmutableJeffrey ImmutableJeffrey commented Apr 21, 2026

Summary

  • Adds the public ImmutableAudience static entry point.
  • Composes DiskStore, EventQueue, HttpTransport, ConsentStore, and Identity into the Init / Track / Identify / Alias / Reset / DeleteData / SetConsent / FlushAsync / Shutdown surface.
  • Serialises the background drain against PurgeAll and ApplyAnonymousDowngrade via EventQueue._drainLock.
  • Implements the consent state machine: None discards all events, Anonymous drops identify/alias and strips userId from track, Full ships everything.
  • Routes all public-method exceptions through the OnError callback.
  • Performs final synchronous drain, drain-thread join with timeout, and idempotent Shutdown.

Linear:

  • SDK-147 — primary
  • SDK-144 — closes: Identify / Alias / Reset public API with Full-consent enforcement
  • SDK-143 — closes the bulk: 3-state machine, queue purge on →None, Full→Anonymous downgrade, fire-and-forget PUT to /v1/audience/tracking-consent

@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch from 3da9923 to a8815e6 Compare April 21, 2026 23:05
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-2 branch from cf64b56 to 1720e85 Compare April 21, 2026 23:05
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch from a8815e6 to 5aefd17 Compare April 21, 2026 23:08
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-2 branch from 1720e85 to 2cbe87f Compare April 21, 2026 23:08
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch from 5aefd17 to f9b8af1 Compare April 21, 2026 23:20
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-2 branch from 2cbe87f to 6fecbed Compare April 21, 2026 23:20
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch from f9b8af1 to f8279bd Compare April 22, 2026 00:31
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-2 branch from fe69470 to fd34007 Compare April 22, 2026 00:57
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch 2 times, most recently from e11b0fe to 9dd780b Compare April 22, 2026 01:04
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-2 branch from fd34007 to 2268fba Compare April 22, 2026 01:04
Base automatically changed from feat/sdk-147-singleton-2 to main April 22, 2026 01:27
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.
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch from 9dd780b to c8a7a52 Compare April 22, 2026 01:32
…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.
Comment thread src/Packages/Audience/Runtime/ImmutableAudience.cs
Comment thread src/Packages/Audience/Runtime/ImmutableAudience.cs
Comment thread src/Packages/Audience/Runtime/ImmutableAudience.cs Outdated
Comment thread src/Packages/Audience/Runtime/ImmutableAudience.cs
Comment thread src/Packages/Audience/Runtime/ImmutableAudience.cs Outdated
@nattb8 nattb8 marked this pull request as ready for review April 22, 2026 03:44
@nattb8 nattb8 requested review from a team as code owners April 22, 2026 03:44
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>
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch 2 times, most recently from 57a41d9 to 69440fe Compare April 22, 2026 06:02
ImmutableJeffrey and others added 3 commits April 22, 2026 16:13
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>
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/sdk-147-singleton-3 branch from 69440fe to bb3d45f Compare April 22, 2026 06:14

// 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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For CDP, can we make identityType mandatory please. For alias as well.

Copy link
Copy Markdown
Collaborator

@nattb8 nattb8 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@ImmutableJeffrey ImmutableJeffrey merged commit 867c9c4 into main Apr 22, 2026
18 checks passed
@ImmutableJeffrey ImmutableJeffrey deleted the feat/sdk-147-singleton-3 branch April 22, 2026 07:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

2 participants