From 8c93b938560afb9e8de903e3e95740a7a460dd3e Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 3 May 2026 17:38:22 +1000 Subject: [PATCH 01/13] fix(audience-sample): grant SampleApp.Tests internal access for SSOT pass Adds InternalsVisibleTo grants the upcoming SSOT centralisation needs so SampleApp tests compile incrementally as constants are introduced. - examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs (new): grants SampleApp.Tests access to sample-app internals (SampleAppUi, SampleAppCustomEvents, SampleAppCustomEventPropertyKeys). - src/Packages/Audience/Runtime/AssemblyInfo.cs: grants SampleApp.Tests access to SDK runtime internals (AudiencePaths, EventNames, EventPropertyKeys, MessageFields). --- .../audience/Assets/SampleApp/Scripts/AssemblyInfo.cs | 4 ++++ .../Assets/SampleApp/Scripts/AssemblyInfo.cs.meta | 11 +++++++++++ src/Packages/Audience/Runtime/AssemblyInfo.cs | 3 +++ 3 files changed, 18 insertions(+) create mode 100644 examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs create mode 100644 examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta diff --git a/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs new file mode 100644 index 000000000..bb134f0e0 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// Grants test access without exposing these helpers on the SDK consumer surface. +[assembly: InternalsVisibleTo("Immutable.Audience.Samples.SampleApp.Tests")] diff --git a/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta new file mode 100644 index 000000000..089ebcfe1 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95f1c28711234c4699e747945c9ecb23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs index d390acb91..565986f27 100644 --- a/src/Packages/Audience/Runtime/AssemblyInfo.cs +++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs @@ -7,3 +7,6 @@ // JsonReader.DeserializeObject; both stay internal so their // signatures aren't frozen into the public API. [assembly: InternalsVisibleTo("Immutable.Audience.Samples.SampleApp")] + +// SampleApp tests pin against the same internal constants the runtime SDK uses. +[assembly: InternalsVisibleTo("Immutable.Audience.Samples.SampleApp.Tests")] From cd0f28868cfe47e31e4515058211a12e27f055d1 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 18:00:00 +1000 Subject: [PATCH 02/13] refactor(audience-sdk): name HTTP MIME and content-encoding header constants Replaces "application/json" / "gzip" / "Content-Encoding" string literals with Constants.MediaTypeJson / GzipEncoding / ContentEncodingHeader so the messages POST and the consent-sync PUT share one source of truth and the test assertions read from the same constants. - Constants.cs: adds MediaTypeJson, GzipEncoding, ContentEncodingHeader. - HttpTransport.cs: gzip and plain JSON content paths use the new constants. - ImmutableAudience.cs: consent-sync PUT body uses MediaTypeJson. - HttpTransportTests.cs: gzip and plain JSON assertions read from MediaTypeJson and GzipEncoding. --- src/Packages/Audience/Runtime/Core/Constants.cs | 4 ++++ src/Packages/Audience/Runtime/ImmutableAudience.cs | 2 +- src/Packages/Audience/Runtime/Transport/HttpTransport.cs | 6 +++--- .../Audience/Tests/Runtime/Transport/HttpTransportTests.cs | 6 +++--- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index f217812d5..f2be258a6 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -25,6 +25,10 @@ internal static class Constants internal const string ConsentSource = "UnitySDK"; internal const string PublishableKeyHeader = "x-immutable-publishable-key"; + internal const string ContentEncodingHeader = "Content-Encoding"; + + internal const string MediaTypeJson = "application/json"; + internal const string GzipEncoding = "gzip"; internal static string MessagesUrl(string? publishableKey, string? baseUrlOverride = null) => BaseUrl(publishableKey, baseUrlOverride) + MessagesPath; diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index f1f28be7d..9595ec837 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -635,7 +635,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev attempt++; 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"); + request.Content = new StringContent(body, System.Text.Encoding.UTF8, Constants.MediaTypeJson); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) return; diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 6ac6e5357..73250490a 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -89,10 +89,10 @@ internal async Task SendBatchAsync(CancellationToken ct = default) #if IMMUTABLE_AUDIENCE_GZIP var compressed = Gzip.Compress(payload); request.Content = new ByteArrayContent(compressed); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - request.Content.Headers.Add("Content-Encoding", "gzip"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.MediaTypeJson); + request.Content.Headers.Add(Constants.ContentEncodingHeader, Constants.GzipEncoding); #else - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + request.Content = new StringContent(payload, Encoding.UTF8, Constants.MediaTypeJson); #endif using var response = await _client.SendAsync(request, ct).ConfigureAwait(false); diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index d049705eb..772aef674 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -85,8 +85,8 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() await transport.SendBatchAsync(); Assert.AreEqual("pk_imapik-test-key1", capturedKey); - Assert.AreEqual("application/json", capturedContentType); - Assert.AreEqual("gzip", capturedContentEncoding); + Assert.AreEqual(Constants.MediaTypeJson, capturedContentType); + Assert.AreEqual(Constants.GzipEncoding, capturedContentEncoding); var decompressed = DecompressGzip(capturedBody!); StringAssert.StartsWith("{\"messages\":[", decompressed); @@ -116,7 +116,7 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding await transport.SendBatchAsync(); Assert.AreEqual("pk_imapik-test-key1", capturedKey); - Assert.AreEqual("application/json", capturedContentType); + Assert.AreEqual(Constants.MediaTypeJson, capturedContentType); Assert.AreEqual(0, capturedContentEncodingCount, "no Content-Encoding header is permitted in v1"); StringAssert.StartsWith("{\"messages\":[", capturedBody); StringAssert.EndsWith("]}", capturedBody); From 1374c3f928ca9a2a4cef67a1d88ae797a7fe35f0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 18:30:00 +1000 Subject: [PATCH 03/13] refactor(audience-sdk): name HTTP request timeout and backoff schedule Replaces inline 30-second timeout and the 5/10/20/40/60-second backoff ladder with named constants on HttpTransport so the schedule is visible at the top of the class and a tuning change touches one place. - HttpTransport.cs: adds RequestTimeoutSeconds (30) and Backoff{1st,2nd,3rd,4th,Cap}Ms (5k / 10k / 20k / 40k / 60k). - HttpTransport.cs: constructor uses RequestTimeoutSeconds; BackoffMsLocked returns the named constants. --- .../Runtime/Transport/HttpTransport.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 73250490a..beea97068 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -15,6 +15,17 @@ namespace Immutable.Audience // Sends queued events from DiskStore to the Audience backend. internal sealed class HttpTransport : IDisposable { + // How long we wait for one POST before giving up. + // Without this, one stuck request can block everything else. + internal const int RequestTimeoutSeconds = 30; + + // How long we wait before retrying after a failed POST. Doubles each time. + internal const int Backoff1stMs = 5_000; + internal const int Backoff2ndMs = 10_000; + internal const int Backoff3rdMs = 20_000; + internal const int Backoff4thMs = 40_000; + internal const int BackoffCapMs = 60_000; + private readonly DiskStore _store; private readonly string _url; private readonly string _publishableKey; @@ -48,7 +59,7 @@ internal HttpTransport( _client = handler != null ? new HttpClient(handler, disposeHandler: false) : new HttpClient(); - _client.Timeout = TimeSpan.FromSeconds(30); + _client.Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds); _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); } @@ -182,11 +193,11 @@ internal int BackoffMs private int BackoffMsLocked() => _consecutiveFailures switch { <= 0 => 0, - 1 => 5_000, - 2 => 10_000, - 3 => 20_000, - 4 => 40_000, - _ => 60_000, + 1 => Backoff1stMs, + 2 => Backoff2ndMs, + 3 => Backoff3rdMs, + 4 => Backoff4thMs, + _ => BackoffCapMs, }; // Earliest UTC time at which the next attempt may run. From 63fffb7ba762795beafb9e0c45ecffc92bbedc0c Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 18:45:00 +1000 Subject: [PATCH 04/13] refactor(audience-sdk): use HttpStatusCode.TooManyRequests instead of HTTP 429 Drops the inline 429 magic number on both the messages POST retry path and the consent-sync PUT retry path in favour of the named HttpStatusCode value, so a future RFC change or reading hand stays in sync with the BCL. - HttpTransport.cs: messages POST 429 branch reads HttpStatusCode.TooManyRequests. - ImmutableAudience.cs: consent-sync PUT retry condition reads HttpStatusCode.TooManyRequests; adds using System.Net. --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 3 ++- src/Packages/Audience/Runtime/Transport/HttpTransport.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 9595ec837..812854583 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -640,7 +641,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev if (response.IsSuccessStatusCode) return; - if ((int)response.StatusCode == 429 && attempt < maxAttempts) + if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < maxAttempts) { var delay = HttpRetry.ParseRetryAfter(response) ?? TimeSpan.FromMilliseconds(1_000 * (1 << (attempt - 1))); diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index beea97068..04cc1c966 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -125,7 +125,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) $"Batch partially rejected: {rejected} of {batch.Count} events dropped"); } } - else if (statusCode == 429) + else if (statusCode == (int)HttpStatusCode.TooManyRequests) { // 429 is retryable (RFC 6585). Keep the batch, honor Retry-After // if present else use the existing 5xx backoff schedule. No From 6264a15b30ee338d4a024b0382db2b020c23257c Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 19:00:00 +1000 Subject: [PATCH 05/13] refactor(audience-sdk): name session timer-disposal budgets Replaces the inline 500ms double-Start drain budget and the 1s heartbeat drain budget in Session with named constants, so the "quits must not hang" budget is visible at the top of the class and a tuning change touches one place. - Session.cs: adds HeartbeatDrainTimeoutMs (1000) and StartDrainTimeoutMs (500). - Session.cs: Start uses StartDrainTimeoutMs for the prior heartbeat timer drain; DrainHeartbeatTimer uses HeartbeatDrainTimeoutMs. --- src/Packages/Audience/Runtime/Core/Session.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index deb6e17aa..bf36e7687 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -28,6 +28,12 @@ internal sealed class Session : IDisposable // 30s: alt-tab beyond this rolls the session on Resume. internal const int PauseTimeoutMs = 30_000; + // How long we wait for an in-flight heartbeat callback to finish during teardown. + internal const int HeartbeatDrainTimeoutMs = 1_000; + + // How long we wait for the previous heartbeat to clear when Start is called twice. + internal const int StartDrainTimeoutMs = 500; + private readonly TrackDelegate _track; private readonly Func _getUtcNow; private readonly int _heartbeatIntervalMs; @@ -77,8 +83,8 @@ internal void Start() } } - // 500ms budget. Double-Start is a misuse path. - TimerDisposal.DisposeAndWait(oldTimer, TimeSpan.FromMilliseconds(500)); + // Double-Start is a misuse path; StartDrainTimeoutMs caps the wait. + TimerDisposal.DisposeAndWait(oldTimer, TimeSpan.FromMilliseconds(StartDrainTimeoutMs)); // Phase 2: populate new state. Re-check _disposed (may have flipped during drain). string sessionId; @@ -264,7 +270,8 @@ private void SafeTrack(string eventName, Dictionary properties) } // 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. + // _lock (OnHeartbeat re-enters). HeartbeatDrainTimeoutMs caps the + // wait so quits don't hang. Warns on timeout. private void DrainHeartbeatTimer() { Timer? timer; @@ -275,7 +282,7 @@ private void DrainHeartbeatTimer() } if (timer == null) return; - if (!TimerDisposal.DisposeAndWait(timer, TimeSpan.FromSeconds(1))) + if (!TimerDisposal.DisposeAndWait(timer, TimeSpan.FromMilliseconds(HeartbeatDrainTimeoutMs))) { Log.Warn(AudienceLogs.SessionHeartbeatTimeout); } From 9856ecf710c4005658acf8d8c95a7b8bc243e6af Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 19:15:00 +1000 Subject: [PATCH 06/13] refactor(audience-sdk): name consent-sync retry budgets Replaces the inline 4-attempt cap and 1-second base retry on the consent-sync PUT retry loop with named constants on ImmutableAudience so the budget is visible at the top of the class and a tuning change touches one place. - ImmutableAudience.cs: adds ConsentSyncMaxAttempts (4) and ConsentSyncBaseRetryMs (1000). - ImmutableAudience.cs: SyncConsentLevel uses ConsentSyncMaxAttempts and ConsentSyncBaseRetryMs in the 429 retry loop. --- src/Packages/Audience/Runtime/ImmutableAudience.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 812854583..367d96fe0 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -27,6 +27,12 @@ public static class ImmutableAudience // teardown (Session.Dispose, timer drain, queue shutdown, transport // flush, disposes). This keeps the hold time to nanoseconds so a caller // arriving on a different thread is not stranded behind those budgets. + // How many times we retry the consent-sync PUT after a 429. + internal const int ConsentSyncMaxAttempts = 4; + + // How long we wait before the first consent-sync retry. Doubles each time. + internal const int ConsentSyncBaseRetryMs = 1_000; + private static AudienceConfig? _config; private static DiskStore? _store; private static EventQueue? _queue; @@ -625,9 +631,9 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev Task.Run(async () => { - // 429 retried up to 4 attempts (1s/2s/4s or Retry-After). - // Other non-2xx fail fast. - const int maxAttempts = 4; + // 429 retried up to ConsentSyncMaxAttempts attempts (1s/2s/4s + // or Retry-After). Other non-2xx fail fast. + const int maxAttempts = ConsentSyncMaxAttempts; var attempt = 0; try { @@ -644,7 +650,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < maxAttempts) { var delay = HttpRetry.ParseRetryAfter(response) - ?? TimeSpan.FromMilliseconds(1_000 * (1 << (attempt - 1))); + ?? TimeSpan.FromMilliseconds(ConsentSyncBaseRetryMs * (1 << (attempt - 1))); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); continue; } From ce2ddb0ec765ed1ea19c864524edff56f5486ba8 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 19:30:00 +1000 Subject: [PATCH 07/13] refactor(audience-sdk): name queue file glob and atomic-write tmp suffix Replaces the hand-rolled "*.json" globs and ".tmp" atomic-write suffixes spread across DiskStore, ConsentStore, Identity, and the test suite with named constants on AudiencePaths so a rename of either pattern touches one file and runtime / tests stay in sync. - AudiencePaths.cs: adds QueueFileExtension (.json), QueueGlob ("*.json"), TempFileSuffix (.tmp). - DiskStore.cs: queueDir uses AudiencePaths.QueueDir; the "*.json" globs and ".tmp" suffix go through the new constants; new file naming uses QueueFileExtension. - ConsentStore.cs, Identity.cs: atomic-write tmpPath uses AudiencePaths.TempFileSuffix. - Test suite (DiskStoreTests, SessionTests, ImmutableAudienceTests, ThreadSafetyStressTests): Directory.GetFiles globs read from AudiencePaths.QueueGlob. - SampleAppLiveFireTests.cs: SDK persistence dir comes from AudiencePaths.AudienceDir, so the sample-app side no longer needs its own SdkPersistedDirName mirror. --- .../Tests/Runtime/SampleAppLiveFireTests.cs | 2 +- .../Audience/Runtime/Core/AudiencePaths.cs | 7 ++ .../Audience/Runtime/Core/ConsentStore.cs | 2 +- .../Audience/Runtime/Core/Identity.cs | 2 +- .../Audience/Runtime/Transport/DiskStore.cs | 16 ++--- .../Tests/Runtime/Core/SessionTests.cs | 4 +- .../Tests/Runtime/ImmutableAudienceTests.cs | 70 +++++++++---------- .../Tests/Runtime/ThreadSafetyStressTests.cs | 4 +- .../Tests/Runtime/Transport/DiskStoreTests.cs | 4 +- 9 files changed, 59 insertions(+), 52 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index 43a423302..cee8ca7af 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -36,7 +36,7 @@ public void SetUp() // (and identity/queue) to disk under /imtbl_audience. // Without wiping it, a SetConsent(None) from a prior test leaks into // the next test's Init via ConsentStore.Load. - var sdkDir = System.IO.Path.Combine(Application.persistentDataPath, SampleAppUi.SdkPersistedDirName); + var sdkDir = AudiencePaths.AudienceDir(Application.persistentDataPath); if (System.IO.Directory.Exists(sdkDir)) System.IO.Directory.Delete(sdkDir, recursive: true); diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs index 52fb40c33..dbcc604d7 100644 --- a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -9,6 +9,13 @@ internal static class AudiencePaths private const string ConsentFileName = "consent"; private const string QueueDirName = "queue"; + // Queue files are named _.json. + internal const string QueueFileExtension = ".json"; + internal const string QueueGlob = "*" + QueueFileExtension; + + // Files ending in this suffix are mid-write and must not be read. + internal const string TempFileSuffix = ".tmp"; + internal static string AudienceDir(string persistentDataPath) => Path.Combine(persistentDataPath, RootDirName); diff --git a/src/Packages/Audience/Runtime/Core/ConsentStore.cs b/src/Packages/Audience/Runtime/Core/ConsentStore.cs index 58ea20326..515abff22 100644 --- a/src/Packages/Audience/Runtime/Core/ConsentStore.cs +++ b/src/Packages/Audience/Runtime/Core/ConsentStore.cs @@ -12,7 +12,7 @@ internal static void Save(string persistentDataPath, ConsentLevel level) Directory.CreateDirectory(AudiencePaths.AudienceDir(persistentDataPath)); var filePath = AudiencePaths.ConsentFile(persistentDataPath); - var tmpPath = filePath + ".tmp"; + var tmpPath = filePath + AudiencePaths.TempFileSuffix; File.WriteAllText(tmpPath, ((int)level).ToString()); diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 3c2e1943a..5699602bd 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -92,7 +92,7 @@ internal static void ClearCache() // New install: generate a UUID and persist it atomically. // Write to a .tmp file first so a crash mid-write leaves no corrupt file. var newId = Guid.NewGuid().ToString(); - var tmpPath = filePath + ".tmp"; + var tmpPath = filePath + AudiencePaths.TempFileSuffix; File.WriteAllText(tmpPath, newId); try diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index b407a7f37..b0f794a04 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -23,17 +23,17 @@ internal sealed class DiskStore internal DiskStore(string persistentDataPath) { - _queueDir = Path.Combine(persistentDataPath, "imtbl_audience", "queue"); + _queueDir = AudiencePaths.QueueDir(persistentDataPath); Directory.CreateDirectory(_queueDir); - _cachedCount = Directory.GetFiles(_queueDir, "*.json").Length; + _cachedCount = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob).Length; } // Atomically writes json as a new event file. internal void Write(string json) { - var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}.json"; + var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}{AudiencePaths.QueueFileExtension}"; var finalPath = Path.Combine(_queueDir, fileName); - var tmpPath = finalPath + ".tmp"; + var tmpPath = finalPath + AudiencePaths.TempFileSuffix; File.WriteAllText(tmpPath, json); @@ -66,7 +66,7 @@ internal IReadOnlyList ReadBatch(int maxSize) var result = new List(); // Sort by filename (ticks prefix) → oldest first - var files = Directory.GetFiles(_queueDir, "*.json") + var files = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob) .OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal); foreach (var path in files) @@ -116,7 +116,7 @@ private static bool TryDelete(string path) internal void DeleteAll() { string[] paths; - try { paths = Directory.GetFiles(_queueDir, "*.json"); } + try { paths = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob); } catch (DirectoryNotFoundException) { return; } foreach (var path in paths) @@ -128,7 +128,7 @@ internal void DeleteAll() internal void ApplyAnonymousDowngrade() { string[] paths; - try { paths = Directory.GetFiles(_queueDir, "*.json"); } + try { paths = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob); } catch (DirectoryNotFoundException) { return; } foreach (var path in paths) @@ -179,7 +179,7 @@ private void RewriteTrackWithoutUserId(string path, Dictionary m try { var rewritten = Json.Serialize(msg); - var tmp = path + ".tmp"; + var tmp = path + AudiencePaths.TempFileSuffix; File.WriteAllText(tmp, rewritten); try { File.Move(tmp, path); } catch (IOException) diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs index 926fcbc09..ae807ef44 100644 --- a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs @@ -712,7 +712,7 @@ private string[] ReadQueueFiles() { var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); if (!Directory.Exists(queueDir)) return Array.Empty(); - return Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToArray(); + return Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToArray(); } [Test] @@ -811,7 +811,7 @@ public void Reset_StartsNewSession_DoesNotEmitSessionEnd() // only see post-Reset events. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); var firstAnonymousId = Identity.Get(_testDir); Assert.IsNotNull(firstAnonymousId, "first session should have minted an anonymousId"); diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index c94032cc4..5717ea101 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -190,7 +190,7 @@ public void ContextProvider_Set_MergesFieldsIntoEveryMessageContext() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"userAgent\":\"TestOS 1.0\"") && @@ -219,7 +219,7 @@ public void ContextProvider_Set_MergesOnIdentifyPath() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"type\":\"identify\"") && @@ -238,7 +238,7 @@ public void ContextProvider_ThrowingDelegate_SwallowsAndShipsBaseContext() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"unit_test_event\"") && b.Contains("\"library\":")), "event should still ship with base context when ContextProvider throws"); @@ -254,7 +254,7 @@ public void ContextProvider_ReturnsNull_ShipsBaseContext() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"unit_test_event\"") && b.Contains("\"library\":")), "event should still ship with base context when ContextProvider returns null"); @@ -329,7 +329,7 @@ public void Track_IEventMissingRequiredField_DropsWithWarn() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsFalse(contents.Any(c => c.Contains("\"purchase\"")), "purchase event with missing required Value must be dropped, not enqueued"); @@ -363,7 +363,7 @@ public void Track_NullOrEmptyEventName_DoesNotEnqueue() // key ordering, escape style). var queueDir = AudiencePaths.QueueDir(_testDir); if (!Directory.Exists(queueDir)) return; - foreach (var file in Directory.GetFiles(queueDir, "*.json")) + foreach (var file in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) { var msg = JsonReader.DeserializeObject(File.ReadAllText(file)); if ((string)msg["type"] != "track") continue; @@ -497,7 +497,7 @@ public void Track_CustomEvent_WritesEventToDisk() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var files = Directory.GetFiles(queueDir, "*.json"); + var files = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob); // game_launch + crafting_started Assert.GreaterOrEqual(files.Length, 2); @@ -515,7 +515,7 @@ public void Track_NoProperties_WritesEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"main_menu_opened\""))); } @@ -535,7 +535,7 @@ public void Track_ConsentNone_DoesNotEnqueue() return; } - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length); + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length); } // ----------------------------------------------------------------- @@ -563,13 +563,13 @@ public void Track_TypedEvent_NullEventName_IsDropped() // assertion counts only our test event. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); Assert.DoesNotThrow(() => ImmutableAudience.Track(new NullNameEvent())); Assert.DoesNotThrow(() => ImmutableAudience.Track(new EmptyNameEvent())); ImmutableAudience.FlushQueueToDiskForTesting(); - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length, + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, "IEvent with null/empty EventName must be dropped, not enqueued"); } @@ -587,7 +587,7 @@ public void Track_TypedProgression_WritesCorrectEventName() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"progression\"") && c.Contains("\"complete\""))); @@ -606,7 +606,7 @@ public void Track_TypedPurchase_WritesCorrectEventName() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"purchase\""))); } @@ -624,7 +624,7 @@ public void Identify_FullConsent_WritesIdentifyEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"identify\"") && c.Contains("\"76561198012345\""))); @@ -639,7 +639,7 @@ public void Identify_AnonymousConsent_IsIgnored() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsFalse(contents.Any(c => c.Contains("\"identify\"")), "identify should be discarded at Anonymous consent"); @@ -654,7 +654,7 @@ public void Alias_FullConsent_WritesAliasEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"alias\"") && c.Contains("\"steam123\""))); @@ -689,12 +689,12 @@ public void Reset_DiscardsQueuedEventsOnDisk() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - Assert.Greater(Directory.GetFiles(queueDir, "*.json").Length, 0, + Assert.Greater(Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, 0, "precondition: queued event should be on disk before reset"); ImmutableAudience.Reset(); - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length, + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, "Reset must discard queued events on disk to match the Web SDK"); } @@ -712,12 +712,12 @@ public void SetConsent_DowngradeToNone_PurgesQueueOnDiskAndInMemory() var queueDir = AudiencePaths.QueueDir(_testDir); // Force memory → disk so we can verify the purge wipes both layers. ImmutableAudience.FlushQueueToDiskForTesting(); - Assert.Greater(Directory.GetFiles(queueDir, "*.json").Length, 0, + Assert.Greater(Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, 0, "precondition: events queued before downgrade exist on disk"); ImmutableAudience.SetConsent(ConsentLevel.None); - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length, + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, "downgrade to None must purge queued events from disk so they can't leak after revocation"); } @@ -735,7 +735,7 @@ public void SetConsent_DowngradeToNone_DropsInFlightTrack_ThatRacesThePurge() // about our race event only. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); // Gate the Track thread so it's poised to enqueue at the moment SetConsent // completes its purge. We approximate the race by kicking Track off a @@ -757,7 +757,7 @@ public void SetConsent_DowngradeToNone_DropsInFlightTrack_ThatRacesThePurge() ImmutableAudience.FlushQueueToDiskForTesting(); var leaked = Directory.Exists(queueDir) - ? Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText) + ? Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText) .Count(c => c.Contains("\"racing_event\"")) : 0; @@ -787,7 +787,7 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); if (Directory.Exists(queueDir)) - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); // All trackers spin up and block on the barrier so they all release // simultaneously. The main thread joins the barrier too and fires @@ -813,7 +813,7 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() int leaked = 0; if (Directory.Exists(queueDir)) { - leaked = Directory.GetFiles(queueDir, "*.json") + leaked = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .Count(c => c.Contains("\"race_stress\"")); } @@ -935,7 +935,7 @@ public void SetConsent_ConcurrentUpgradeFromNone_StartsOneSession_StressTest() var queueDir = AudiencePaths.QueueDir(_testDir); var sessionStarts = Directory.Exists(queueDir) - ? Directory.GetFiles(queueDir, "*.json") + ? Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .Count(c => c.Contains("\"session_start\"")) : 0; @@ -979,7 +979,7 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); if (Directory.Exists(queueDir)) - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); var barrier = new Barrier(trackersPerIteration + 1); var trackers = new Task[trackersPerIteration]; @@ -1001,7 +1001,7 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() int userIdLeaks = 0; if (Directory.Exists(queueDir)) { - userIdLeaks = Directory.GetFiles(queueDir, "*.json") + userIdLeaks = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .Count(c => c.Contains($"\"{testUserId}\"")); } @@ -1098,7 +1098,7 @@ public void Init_FiresGameLaunch_Automatically() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"")), "Init should auto-fire game_launch"); @@ -1113,7 +1113,7 @@ public void Init_GameLaunch_IncludesDistributionPlatform() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"") && c.Contains("\"steam\""))); @@ -1173,7 +1173,7 @@ public void Init_ConsentNone_DoesNotFireGameLaunch() return; } - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsFalse(contents.Any(c => c.Contains("\"game_launch\""))); } @@ -1193,7 +1193,7 @@ public void Init_GameLaunch_IncludesLaunchContextProviderFields() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var launchFile = Directory.GetFiles(queueDir, "*.json") + var launchFile = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .FirstOrDefault(c => c.Contains("\"game_launch\"")); Assert.IsNotNull(launchFile, "game_launch should have been enqueued"); @@ -1217,7 +1217,7 @@ public void Init_GameLaunch_ConfigDistributionPlatformOverridesProvider() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var launchFile = Directory.GetFiles(queueDir, "*.json") + var launchFile = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .First(c => c.Contains("\"game_launch\"")); StringAssert.Contains("\"distributionPlatform\":\"steam\"", launchFile); @@ -1235,7 +1235,7 @@ public void Init_GameLaunch_ProviderThrows_StillFiresEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"")), "game_launch must still ship when the context provider throws"); @@ -1318,7 +1318,7 @@ public void FullToAnonymous_StripsUserIdFromQueuedTrackAndDropsIdentifyAlias() ImmutableAudience.SetConsent(ConsentLevel.Anonymous); var queueDir = AudiencePaths.QueueDir(_testDir); - var files = Directory.GetFiles(queueDir, "*.json"); + var files = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob); foreach (var f in files) { @@ -1342,7 +1342,7 @@ public void FullToAnonymous_FutureTracksOmitUserId() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - var trackFiles = Directory.GetFiles(queueDir, "*.json") + var trackFiles = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(f => JsonReader.DeserializeObject(File.ReadAllText(f))) .Where(m => (string)m["type"] == "track" && m.ContainsKey("eventName") diff --git a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs index 9e9845c91..2a205706f 100644 --- a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs @@ -73,7 +73,7 @@ private void RunSustainedTrackLoad(int threadCount, int durationSeconds) // measures only what our threads enqueue. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); var threads = new Thread[threadCount]; var firedPerThread = new int[threadCount]; @@ -121,7 +121,7 @@ private void RunSustainedTrackLoad(int threadCount, int durationSeconds) ImmutableAudience.FlushQueueToDiskForTesting(); - int onDisk = Directory.GetFiles(queueDir, "*.json").Length; + int onDisk = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length; Assert.AreEqual(totalFired, onDisk, $"every Track should land on disk; fired={totalFired} onDisk={onDisk} delta={totalFired - onDisk}"); } diff --git a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs index d3ee554a5..96b4ef3b9 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs @@ -33,7 +33,7 @@ public void Write_CreatesJsonFile_InQueueDirectory() _store.Write("{\"event\":\"test\"}"); var queueDir = AudiencePaths.QueueDir(_testDir); - var files = Directory.GetFiles(queueDir, "*.json"); + var files = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob); Assert.AreEqual(1, files.Length, "should have written exactly one event file"); } @@ -44,7 +44,7 @@ public void Write_FileContents_MatchInputJson() _store.Write(json); var queueDir = AudiencePaths.QueueDir(_testDir); - var file = Directory.GetFiles(queueDir, "*.json").Single(); + var file = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Single(); Assert.AreEqual(json, File.ReadAllText(file)); } From 9911cf62b653209edab0fba4250bfc52df1a38c6 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 19:45:00 +1000 Subject: [PATCH 08/13] refactor(audience-sdk): name JSON timestamp and round-trip number formats Replaces the bare format-spec strings "o" and "R" passed to DateTime and float / double ToString calls with named constants on Constants so the wire-shape requirement reads as a contract rather than a mystery character. - Constants.cs: adds IsoTimestampFormat ("o") and RoundTripNumberFormat ("R") with comments noting the backend schema requirement and the round-trip preservation guarantee. - MessageBuilder.cs: BuildBase eventTimestamp uses Constants.IsoTimestampFormat. - Json.cs: float and double serialisation use Constants.RoundTripNumberFormat. - AudienceSample.UI.cs: log-row export timestamp uses Constants.IsoTimestampFormat. --- .../audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs | 2 +- src/Packages/Audience/Runtime/Core/Constants.cs | 6 ++++++ src/Packages/Audience/Runtime/Events/MessageBuilder.cs | 2 +- src/Packages/Audience/Runtime/Utility/Json.cs | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs index 96e22d0fe..1c70fbd78 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs @@ -780,7 +780,7 @@ private VisualElement BuildLogRow(LogEntry entry) private static string FormatLogEntry(LogEntry entry, bool singleLine) { var sb = new StringBuilder() - .Append(entry.Timestamp.ToString("o", CultureInfo.InvariantCulture)) + .Append(entry.Timestamp.ToString(Constants.IsoTimestampFormat, CultureInfo.InvariantCulture)) .Append(" [").Append(entry.Source == LogSource.Sdk ? "SDK" : "APP").Append("] ") .Append(entry.Label); if (!string.IsNullOrEmpty(entry.Body)) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index f2be258a6..d7ea7e888 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -17,6 +17,12 @@ internal static class Constants internal const int MaxBatchSize = 100; internal const int StaleEventDays = 30; internal const int MaxFieldLength = 256; // Backend schema limit. + + // Timestamp format the backend wants on every event. + internal const string IsoTimestampFormat = "o"; + + // Format that lets numbers survive a JSON round-trip unchanged. + internal const string RoundTripNumberFormat = "R"; internal const int ControlPlaneRequestTimeoutSeconds = 30; internal const string LibraryName = "com.immutable.audience"; diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 43f815248..4be3ed240 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -79,7 +79,7 @@ private static Dictionary BuildBase(string type, string packageV { [MessageFields.Type] = type, ["messageId"] = Guid.NewGuid().ToString(), - ["eventTimestamp"] = DateTime.UtcNow.ToString("o"), + ["eventTimestamp"] = DateTime.UtcNow.ToString(Constants.IsoTimestampFormat), ["context"] = new Dictionary { ["library"] = Constants.LibraryName, diff --git a/src/Packages/Audience/Runtime/Utility/Json.cs b/src/Packages/Audience/Runtime/Utility/Json.cs index 724061431..6c67d9168 100644 --- a/src/Packages/Audience/Runtime/Utility/Json.cs +++ b/src/Packages/Audience/Runtime/Utility/Json.cs @@ -60,14 +60,14 @@ private static void WriteValue(StringBuilder sb, object? value, int indent, int if (float.IsNaN(f) || float.IsInfinity(f)) sb.Append("null"); else - sb.Append(f.ToString("R", CultureInfo.InvariantCulture)); + sb.Append(f.ToString(Constants.RoundTripNumberFormat, CultureInfo.InvariantCulture)); } else if (value is double d) { if (double.IsNaN(d) || double.IsInfinity(d)) sb.Append("null"); else - sb.Append(d.ToString("R", CultureInfo.InvariantCulture)); + sb.Append(d.ToString(Constants.RoundTripNumberFormat, CultureInfo.InvariantCulture)); } else if (value is decimal dec) { From e0b6dbefda19c619e1b4ddb3c87a77bf93d2d46c Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 20:00:00 +1000 Subject: [PATCH 09/13] refactor(audience-sdk): introduce ResponseFields and ConsentBodyFields Names the wire-format JSON keys for the messages POST envelope, the messages POST response, and the consent-sync PUT body, so runtime emit and test assert read from the same source. - Constants.cs: adds ResponseFields (MessagesEnvelope, Rejected) for the messages POST envelope and the rejected-count response key. - Constants.cs: adds ConsentBodyFields (Status, Source) for the tracking-consent PUT body. - HttpTransport.cs: BuildPayload writes the envelope under ResponseFields.MessagesEnvelope; ParseRejectedCount reads ResponseFields.Rejected. - ImmutableAudience.cs: SyncConsentLevel writes the PUT body under ConsentBodyFields.Status / Source. - HttpTransportTests.cs: MockHandler responses and StringAssert.StartsWith fixtures read from ResponseFields.MessagesEnvelope / Rejected. - ConsentSyncTests.cs: PUT body asserts read from ConsentBodyFields.Status / Source and derive the expected status string from ConsentLevel.ToLowercaseString. --- .../Audience/Runtime/Core/Constants.cs | 14 +++++++++++ .../Audience/Runtime/ImmutableAudience.cs | 4 ++-- .../Runtime/Transport/HttpTransport.cs | 4 ++-- .../Tests/Runtime/ConsentSyncTests.cs | 6 ++--- .../Runtime/Transport/HttpTransportTests.cs | 24 +++++++++---------- 5 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index d7ea7e888..09d2965f1 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -62,6 +62,20 @@ internal static class MessageTypes internal const string Alias = "alias"; } + // JSON keys for the consent-sync PUT body. + internal static class ConsentBodyFields + { + internal const string Status = "status"; + internal const string Source = "source"; + } + + // JSON keys for the messages POST envelope and response. + internal static class ResponseFields + { + internal const string MessagesEnvelope = "messages"; + internal const string Rejected = "rejected"; + } + // Wire-format field names that cross module boundaries inside the SDK // (read by one module, written by another). internal static class MessageFields diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 367d96fe0..7c7433286 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -623,8 +623,8 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev var body = Json.Serialize(new Dictionary { - ["status"] = level.ToLowercaseString(), - ["source"] = Constants.ConsentSource, + [ConsentBodyFields.Status] = level.ToLowercaseString(), + [ConsentBodyFields.Source] = Constants.ConsentSource, // Explicit null lets the backend distinguish "unknown" from a missing field. ["anonymousId"] = anonymousId!, }); diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 04cc1c966..2e14da13d 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -255,7 +255,7 @@ private void ResetBackoff() // schema (#/components/schemas/MessagesRequest property "messages"). private static string? BuildPayload(IReadOnlyList paths) { - var sb = new StringBuilder("{\"messages\":["); + var sb = new StringBuilder($"{{\"{ResponseFields.MessagesEnvelope}\":["); var count = 0; for (var i = 0; i < paths.Count; i++) @@ -307,7 +307,7 @@ private static async Task ParseRejectedCount(HttpResponseMessage response, try { var parsed = JsonReader.DeserializeObject(body); - if (!parsed.TryGetValue("rejected", out var raw)) return 0; + if (!parsed.TryGetValue(ResponseFields.Rejected, out var raw)) return 0; return raw switch { int i => i, diff --git a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs index f9d31de2c..35bbda457 100644 --- a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs @@ -43,8 +43,8 @@ public void SetConsent_FiresPut_WithExpectedBodyShape() var body = JsonReader.DeserializeObject(put.Body); Assert.AreEqual(Constants.ConsentUrl("pk_imapik-test-key1"), put.Url); - Assert.AreEqual("full", body["status"]); - Assert.AreEqual(Constants.ConsentSource, body["source"]); + Assert.AreEqual(ConsentLevel.Full.ToLowercaseString(), body[ConsentBodyFields.Status]); + Assert.AreEqual(Constants.ConsentSource, body[ConsentBodyFields.Source]); Assert.IsTrue(body.ContainsKey("anonymousId")); Assert.IsNotNull(body["anonymousId"], "upgrade PUT must carry the current anonymousId"); } @@ -66,7 +66,7 @@ public void SetConsent_None_PutCarriesOldAnonymousId_AfterReset() var put = WaitForPut(handler); var body = JsonReader.DeserializeObject(put.Body); - Assert.AreEqual("none", body["status"]); + Assert.AreEqual(ConsentLevel.None.ToLowercaseString(), body[ConsentBodyFields.Status]); Assert.AreEqual(seeded, body["anonymousId"], "revocation PUT must carry the id that was revoked, not null"); Assert.IsFalse(File.Exists(AudiencePaths.IdentityFile(_testDir)), diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index 772aef674..76b8c86f0 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -52,7 +52,7 @@ public async Task SendBatchAsync_200_DeletesFilesFromDisk() _store.Write("{\"type\":\"track\",\"eventName\":\"a\"}"); _store.Write("{\"type\":\"track\",\"eventName\":\"b\"}"); - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":2,\"rejected\":0}"); + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":2,\"{ResponseFields.Rejected}\":0}}"); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); var sent = await transport.SendBatchAsync(); @@ -72,7 +72,7 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() string? capturedContentType = null; string? capturedContentEncoding = null; // Read body inside the callback. The request content is disposed after SendAsync returns. - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => { capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); @@ -89,7 +89,7 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() Assert.AreEqual(Constants.GzipEncoding, capturedContentEncoding); var decompressed = DecompressGzip(capturedBody!); - StringAssert.StartsWith("{\"messages\":[", decompressed); + StringAssert.StartsWith($"{{\"{ResponseFields.MessagesEnvelope}\":[", decompressed); StringAssert.EndsWith("]}", decompressed); StringAssert.Contains("\"eventName\":\"test\"", decompressed); } @@ -103,7 +103,7 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding string? capturedContentType = null; int capturedContentEncodingCount = -1; string? capturedBody = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => { capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); @@ -118,7 +118,7 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding Assert.AreEqual("pk_imapik-test-key1", capturedKey); Assert.AreEqual(Constants.MediaTypeJson, capturedContentType); Assert.AreEqual(0, capturedContentEncodingCount, "no Content-Encoding header is permitted in v1"); - StringAssert.StartsWith("{\"messages\":[", capturedBody); + StringAssert.StartsWith($"{{\"{ResponseFields.MessagesEnvelope}\":[", capturedBody); StringAssert.EndsWith("]}", capturedBody); StringAssert.Contains("\"eventName\":\"test\"", capturedBody); } @@ -130,7 +130,7 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForTestKey() _store.Write("{\"type\":\"track\"}"); HttpRequestMessage? captured = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => captured = req); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); @@ -145,7 +145,7 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey() _store.Write("{\"type\":\"track\"}"); HttpRequestMessage? captured = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => captured = req); using var transport = new HttpTransport(_store, "pk_imapik-prodkey", handler: handler); @@ -160,7 +160,7 @@ public async Task SendBatchAsync_BaseUrlOverride_WinsOverKeyPrefix() _store.Write("{\"type\":\"track\"}"); HttpRequestMessage? captured = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => captured = req); const string custom = "https://api.dev.immutable.com"; // Test-prefixed key would resolve to Sandbox on its own; the @@ -298,7 +298,7 @@ public async Task SendBatchAsync_429ThenSuccess_DeliversBatchAndClearsBackoff() return callCount == 1 ? new HttpResponseMessage((HttpStatusCode)429) : new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{\"accepted\":1,\"rejected\":0}") }; + { Content = new StringContent($"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}") }; }); AudienceError? reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", @@ -325,7 +325,7 @@ public async Task SendBatchAsync_200_WithRejected_DeletesFilesAndSurfacesValidat _store.Write("{\"type\":\"track\",\"eventName\":\"a\"}"); _store.Write("{\"type\":\"track\",\"eventName\":\"b\"}"); - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":1}"); + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":1}}"); AudienceError? reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", onError: e => reportedError = e, handler: handler); @@ -343,7 +343,7 @@ public async Task SendBatchAsync_200_ZeroRejected_DoesNotFireOnError() { _store.Write("{\"type\":\"track\",\"eventName\":\"a\"}"); - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}"); + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}"); AudienceError? reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", onError: e => reportedError = e, handler: handler); @@ -468,7 +468,7 @@ public async Task BackoffMs_ResetsAfterSuccess() return callCount <= 2 ? new HttpResponseMessage(HttpStatusCode.InternalServerError) : new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{\"accepted\":1,\"rejected\":0}") }; + { Content = new StringContent($"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}") }; }); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler, getUtcNow: _getUtcNow); From 0f93ad9851de35eb32e43679c4f2c14526570485 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 20:30:00 +1000 Subject: [PATCH 10/13] refactor(audience-sdk): expand MessageFields with envelope keys Adds the 15 wire-format envelope keys callers reach for (eventName, anonymousId, identityType, traits, messageId, eventTimestamp, context, surface, library, libraryVersion, fromId, fromType, toId, toType, properties), and routes runtime emit and downstream callers through the new constants. - Constants.cs: MessageFields gains the new envelope keys, grouped by section (envelope / track / identity / alias / context). - MessageBuilder.cs: BuildBase, Track, Identify, Alias all read keys from MessageFields. - ImmutableAudience.cs: DeleteData query string and consent-sync body anonymousId go through MessageFields; context-overlay TryGetValue reads MessageFields.Context. - AudienceSample.cs: typed/string/custom event echo, identify form, and alias form go through MessageFields.Properties / IdentityType / Traits. - Test suite (MessageBuilderTests, ImmutableAudienceTests, ConsentSyncTests, JsonReaderTests, JsonTests, EventQueueTests, DeleteDataTests): bracket-style envelope key access reads from MessageFields. --- .../SampleApp/Scripts/AudienceSample.cs | 14 ++--- .../Audience/Runtime/Core/Constants.cs | 26 ++++++++ .../Audience/Runtime/Events/MessageBuilder.cs | 32 +++++----- .../Audience/Runtime/ImmutableAudience.cs | 10 +-- .../Tests/Runtime/ConsentSyncTests.cs | 6 +- .../Runtime/Events/MessageBuilderTests.cs | 62 +++++++++---------- .../Tests/Runtime/ImmutableAudienceTests.cs | 14 ++--- .../Runtime/Transport/EventQueueTests.cs | 4 +- .../Tests/Runtime/Utility/JsonReaderTests.cs | 20 +++--- 9 files changed, 107 insertions(+), 81 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index e6fbe99a7..225994742 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -132,7 +132,7 @@ private void OnSendCatalogueEvent(EventSpec spec, Dictionary RunAndLog("track()", () => var props = string.IsNullOrEmpty(f.RawProps) ? null : JsonReader.DeserializeObject(f.RawProps); ImmutableAudience.Track(f.Name, props); var echo = new Dictionary { ["event"] = f.Name }; - if (props != null) echo["properties"] = props; + if (props != null) echo[MessageFields.Properties] = props; return Json.Serialize(echo, 2); }); @@ -199,10 +199,10 @@ private void OnIdentify() => RunAndLog("identify()", () => var payload = new Dictionary { ["id"] = f.Id, - ["identityType"] = f.Type, + [MessageFields.IdentityType] = f.Type, ["accepted"] = accepted, }; - if (traits != null) payload["traits"] = traits; + if (traits != null) payload[MessageFields.Traits] = traits; return Json.Serialize(payload, 2); }); @@ -233,8 +233,8 @@ private void OnAlias() => RunAndLog("alias()", () => } return Json.Serialize(new Dictionary { - ["from"] = new Dictionary { ["id"] = f.FromId, ["identityType"] = f.FromType }, - ["to"] = new Dictionary { ["id"] = f.ToId, ["identityType"] = f.ToType }, + ["from"] = new Dictionary { ["id"] = f.FromId, [MessageFields.IdentityType] = f.FromType }, + ["to"] = new Dictionary { ["id"] = f.ToId, [MessageFields.IdentityType] = f.ToType }, ["accepted"] = accepted, }, 2); }); diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 09d2965f1..62189569b 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -80,8 +80,34 @@ internal static class ResponseFields // (read by one module, written by another). internal static class MessageFields { + // Envelope keys present on every message internal const string Type = "type"; + internal const string MessageId = "messageId"; + internal const string EventTimestamp = "eventTimestamp"; + internal const string Context = "context"; + internal const string Surface = "surface"; + + // Track envelope + internal const string EventName = "eventName"; + internal const string Properties = "properties"; + + // Identity envelope (track, identify, alias) + internal const string AnonymousId = "anonymousId"; internal const string UserId = "userId"; + + // Identify envelope + internal const string IdentityType = "identityType"; + internal const string Traits = "traits"; + + // Alias envelope + internal const string FromId = "fromId"; + internal const string FromType = "fromType"; + internal const string ToId = "toId"; + internal const string ToType = "toType"; + + // Context dictionary keys + internal const string Library = "library"; + internal const string LibraryVersion = "libraryVersion"; } /// diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 4be3ed240..fd0488ecb 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -15,10 +15,10 @@ internal static Dictionary Track( Dictionary? properties = null) { var msg = BuildBase(MessageTypes.Track, packageVersion); - msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); + msg[MessageFields.EventName] = Truncate(eventName, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); + msg[MessageFields.AnonymousId] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); @@ -26,7 +26,7 @@ internal static Dictionary Track( if (properties != null && properties.Count > 0) { TruncateStringValues(properties); - msg["properties"] = properties; + msg[MessageFields.Properties] = properties; } return msg; @@ -42,17 +42,17 @@ internal static Dictionary Identify( var msg = BuildBase(MessageTypes.Identify, packageVersion); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); + msg[MessageFields.AnonymousId] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); - msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); + msg[MessageFields.IdentityType] = Truncate(identityType, Constants.MaxFieldLength); if (traits != null && traits.Count > 0) { TruncateStringValues(traits); - msg["traits"] = traits; + msg[MessageFields.Traits] = traits; } return msg; @@ -66,10 +66,10 @@ internal static Dictionary Alias( string packageVersion) { var msg = BuildBase(MessageTypes.Alias, packageVersion); - msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength); - msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength); - msg["toId"] = Truncate(toId, Constants.MaxFieldLength); - msg["toType"] = Truncate(toType, Constants.MaxFieldLength); + msg[MessageFields.FromId] = Truncate(fromId, Constants.MaxFieldLength); + msg[MessageFields.FromType] = Truncate(fromType, Constants.MaxFieldLength); + msg[MessageFields.ToId] = Truncate(toId, Constants.MaxFieldLength); + msg[MessageFields.ToType] = Truncate(toType, Constants.MaxFieldLength); return msg; } @@ -78,14 +78,14 @@ private static Dictionary BuildBase(string type, string packageV return new Dictionary { [MessageFields.Type] = type, - ["messageId"] = Guid.NewGuid().ToString(), - ["eventTimestamp"] = DateTime.UtcNow.ToString(Constants.IsoTimestampFormat), - ["context"] = new Dictionary + [MessageFields.MessageId] = Guid.NewGuid().ToString(), + [MessageFields.EventTimestamp] = DateTime.UtcNow.ToString(Constants.IsoTimestampFormat), + [MessageFields.Context] = new Dictionary { - ["library"] = Constants.LibraryName, - ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) + [MessageFields.Library] = Constants.LibraryName, + [MessageFields.LibraryVersion] = Truncate(packageVersion, Constants.MaxFieldLength) }, - ["surface"] = Constants.Surface + [MessageFields.Surface] = Constants.Surface }; } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 7c7433286..44cafb445 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -441,7 +441,7 @@ public static Task DeleteData(string? userId = null) string query; if (!string.IsNullOrEmpty(userId)) { - query = "userId=" + Uri.EscapeDataString(userId); + query = $"{MessageFields.UserId}=" + Uri.EscapeDataString(userId); } else { @@ -449,7 +449,7 @@ public static Task DeleteData(string? userId = null) var anonymousId = Identity.Get(config.PersistentDataPath!); if (string.IsNullOrEmpty(anonymousId)) return Task.CompletedTask; - query = "anonymousId=" + Uri.EscapeDataString(anonymousId); + query = $"{MessageFields.AnonymousId}=" + Uri.EscapeDataString(anonymousId); } var url = Constants.DataUrl(config.PublishableKey, config.BaseUrl) + "?" + query; @@ -626,7 +626,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev [ConsentBodyFields.Status] = level.ToLowercaseString(), [ConsentBodyFields.Source] = Constants.ConsentSource, // Explicit null lets the backend distinguish "unknown" from a missing field. - ["anonymousId"] = anonymousId!, + [MessageFields.AnonymousId] = anonymousId!, }); Task.Run(async () => @@ -925,10 +925,10 @@ private static void MergeUnityContext(Dictionary? msg) } if (extra == null) return; - if (!(msg.TryGetValue("context", out var ctxObj) && ctxObj is Dictionary ctx)) + if (!(msg.TryGetValue(MessageFields.Context, out var ctxObj) && ctxObj is Dictionary ctx)) { ctx = new Dictionary(); - msg["context"] = ctx; + msg[MessageFields.Context] = ctx; } foreach (var kv in extra) diff --git a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs index 35bbda457..d684622df 100644 --- a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs @@ -45,8 +45,8 @@ public void SetConsent_FiresPut_WithExpectedBodyShape() Assert.AreEqual(Constants.ConsentUrl("pk_imapik-test-key1"), put.Url); Assert.AreEqual(ConsentLevel.Full.ToLowercaseString(), body[ConsentBodyFields.Status]); Assert.AreEqual(Constants.ConsentSource, body[ConsentBodyFields.Source]); - Assert.IsTrue(body.ContainsKey("anonymousId")); - Assert.IsNotNull(body["anonymousId"], "upgrade PUT must carry the current anonymousId"); + Assert.IsTrue(body.ContainsKey(MessageFields.AnonymousId)); + Assert.IsNotNull(body[MessageFields.AnonymousId], "upgrade PUT must carry the current anonymousId"); } [Test] @@ -67,7 +67,7 @@ public void SetConsent_None_PutCarriesOldAnonymousId_AfterReset() var body = JsonReader.DeserializeObject(put.Body); Assert.AreEqual(ConsentLevel.None.ToLowercaseString(), body[ConsentBodyFields.Status]); - Assert.AreEqual(seeded, body["anonymousId"], + Assert.AreEqual(seeded, body[MessageFields.AnonymousId], "revocation PUT must carry the id that was revoked, not null"); Assert.IsFalse(File.Exists(AudiencePaths.IdentityFile(_testDir)), "precondition: Identity.Reset ran"); diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index abe12f166..75b597428 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -15,12 +15,12 @@ public void Track_RequiredFieldsPresent() { var result = MessageBuilder.Track("level_complete", "anon-1", null, PackageVersion); - Assert.AreEqual("track", result["type"]); - Assert.IsTrue(result.ContainsKey("messageId")); - Assert.IsTrue(result.ContainsKey("eventTimestamp")); - Assert.IsTrue(result.ContainsKey("context")); - Assert.IsTrue(result.ContainsKey("surface")); - Assert.AreEqual("level_complete", result["eventName"]); + Assert.AreEqual("track", result[MessageFields.Type]); + Assert.IsTrue(result.ContainsKey(MessageFields.MessageId)); + Assert.IsTrue(result.ContainsKey(MessageFields.EventTimestamp)); + Assert.IsTrue(result.ContainsKey(MessageFields.Context)); + Assert.IsTrue(result.ContainsKey(MessageFields.Surface)); + Assert.AreEqual("level_complete", result[MessageFields.EventName]); } [Test] @@ -30,7 +30,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() var result = MessageBuilder.Track(longName, null, null, PackageVersion); - Assert.AreEqual(256, ((string)result["eventName"]).Length); + Assert.AreEqual(256, ((string)result[MessageFields.EventName]).Length); } [Test] @@ -38,7 +38,7 @@ public void Track_NullUserId_NotPresentInDict() { var result = MessageBuilder.Track("evt", "anon-1", null, PackageVersion); - Assert.IsFalse(result.ContainsKey("userId")); + Assert.IsFalse(result.ContainsKey(MessageFields.UserId)); } [Test] @@ -46,8 +46,8 @@ public void Track_NonNullUserId_PresentInDict() { var result = MessageBuilder.Track("evt", "anon-1", "user-99", PackageVersion); - Assert.IsTrue(result.ContainsKey("userId")); - Assert.AreEqual("user-99", result["userId"]); + Assert.IsTrue(result.ContainsKey(MessageFields.UserId)); + Assert.AreEqual("user-99", result[MessageFields.UserId]); } [Test] @@ -55,10 +55,10 @@ public void Identify_TypeAndIdentityFieldsPresent() { var result = MessageBuilder.Identify("anon-42", "user-42", "steam", PackageVersion); - Assert.AreEqual("identify", result["type"]); - Assert.AreEqual("anon-42", result["anonymousId"]); - Assert.AreEqual("user-42", result["userId"]); - Assert.AreEqual("steam", result["identityType"]); + Assert.AreEqual("identify", result[MessageFields.Type]); + Assert.AreEqual("anon-42", result[MessageFields.AnonymousId]); + Assert.AreEqual("user-42", result[MessageFields.UserId]); + Assert.AreEqual("steam", result[MessageFields.IdentityType]); } [Test] @@ -66,11 +66,11 @@ public void Alias_AllFourFieldsPresent() { var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", PackageVersion); - Assert.AreEqual("alias", result["type"]); - Assert.AreEqual("from-id", result["fromId"]); - Assert.AreEqual("email", result["fromType"]); - Assert.AreEqual("to-id", result["toId"]); - Assert.AreEqual("steam", result["toType"]); + Assert.AreEqual("alias", result[MessageFields.Type]); + Assert.AreEqual("from-id", result[MessageFields.FromId]); + Assert.AreEqual("email", result[MessageFields.FromType]); + Assert.AreEqual("to-id", result[MessageFields.ToId]); + Assert.AreEqual("steam", result[MessageFields.ToType]); } [Test] @@ -82,9 +82,9 @@ public void AllMessages_ContextContainsLibraryAndLibraryVersion() foreach (var msg in new[] { track, identify, alias }) { - var ctx = (Dictionary)msg["context"]; - Assert.AreEqual(Constants.LibraryName, ctx["library"]); - Assert.AreEqual(PackageVersion, ctx["libraryVersion"]); + var ctx = (Dictionary)msg[MessageFields.Context]; + Assert.AreEqual(Constants.LibraryName, ctx[MessageFields.Library]); + Assert.AreEqual(PackageVersion, ctx[MessageFields.LibraryVersion]); } } @@ -95,9 +95,9 @@ public void AllMessages_SurfaceIsUnity() var identify = MessageBuilder.Identify(null, "u1", "steam", PackageVersion); var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); - Assert.AreEqual("unity", track["surface"]); - Assert.AreEqual("unity", identify["surface"]); - Assert.AreEqual("unity", alias["surface"]); + Assert.AreEqual("unity", track[MessageFields.Surface]); + Assert.AreEqual("unity", identify[MessageFields.Surface]); + Assert.AreEqual("unity", alias[MessageFields.Surface]); } [Test] @@ -105,7 +105,7 @@ public void AllMessages_MessageId_ParsesAsGuid() { foreach (var msg in EveryMessageType()) { - var id = (string)msg["messageId"]; + var id = (string)msg[MessageFields.MessageId]; Assert.IsTrue(Guid.TryParse(id, out _), $"messageId must parse as Guid; got: '{id}'"); } @@ -117,7 +117,7 @@ public void Track_MessageId_IsUniquePerCall() // Backend deduplicates on messageId; collisions silently drop events. var ids = new HashSet(); for (var i = 0; i < 1000; i++) - ids.Add((string)MessageBuilder.Track("evt", null, null, PackageVersion)["messageId"]); + ids.Add((string)MessageBuilder.Track("evt", null, null, PackageVersion)[MessageFields.MessageId]); Assert.AreEqual(1000, ids.Count); } @@ -130,7 +130,7 @@ public void AllMessages_EventTimestamp_IsRoundTripIso8601Utc() var before = DateTime.UtcNow.AddSeconds(-2); foreach (var msg in EveryMessageType()) { - var ts = (string)msg["eventTimestamp"]; + var ts = (string)msg[MessageFields.EventTimestamp]; Assert.IsTrue( DateTime.TryParseExact(ts, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed), @@ -148,9 +148,9 @@ public void AllMessages_Context_LibraryAndLibraryVersionAreNonEmptyStrings() { foreach (var msg in EveryMessageType()) { - var ctx = (Dictionary)msg["context"]; - var library = ctx["library"] as string; - var libraryVersion = ctx["libraryVersion"] as string; + var ctx = (Dictionary)msg[MessageFields.Context]; + var library = ctx[MessageFields.Library] as string; + var libraryVersion = ctx[MessageFields.LibraryVersion] as string; Assert.IsFalse(string.IsNullOrEmpty(library), "context.library must be non-empty string"); Assert.IsFalse(string.IsNullOrEmpty(libraryVersion), "context.libraryVersion must be non-empty string"); } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 5717ea101..0d397ca53 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -366,7 +366,7 @@ public void Track_NullOrEmptyEventName_DoesNotEnqueue() foreach (var file in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) { var msg = JsonReader.DeserializeObject(File.ReadAllText(file)); - if ((string)msg["type"] != "track") continue; + if ((string)msg[MessageFields.Type] != "track") continue; if (!msg.TryGetValue("eventName", out var eventNameObj)) Assert.Fail($"track message {Path.GetFileName(file)} missing eventName field"); @@ -1323,11 +1323,11 @@ public void FullToAnonymous_StripsUserIdFromQueuedTrackAndDropsIdentifyAlias() foreach (var f in files) { var msg = JsonReader.DeserializeObject(File.ReadAllText(f)); - var type = (string)msg["type"]; + var type = (string)msg[MessageFields.Type]; Assert.AreNotEqual("identify", type, "identify must be purged on Full -> Anonymous"); Assert.AreNotEqual("alias", type, "alias must be purged on Full -> Anonymous"); if (type == "track") - Assert.IsFalse(msg.ContainsKey("userId"), "userId must be stripped from queued track on Full -> Anonymous"); + Assert.IsFalse(msg.ContainsKey(MessageFields.UserId), "userId must be stripped from queued track on Full -> Anonymous"); } } @@ -1344,13 +1344,13 @@ public void FullToAnonymous_FutureTracksOmitUserId() var queueDir = AudiencePaths.QueueDir(_testDir); var trackFiles = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(f => JsonReader.DeserializeObject(File.ReadAllText(f))) - .Where(m => (string)m["type"] == "track" - && m.ContainsKey("eventName") - && (string)m["eventName"] == "tracked_after_downgrade") + .Where(m => (string)m[MessageFields.Type] == "track" + && m.ContainsKey(MessageFields.EventName) + && (string)m[MessageFields.EventName] == "tracked_after_downgrade") .ToList(); Assert.AreEqual(1, trackFiles.Count); - Assert.IsFalse(trackFiles[0].ContainsKey("userId"), + Assert.IsFalse(trackFiles[0].ContainsKey(MessageFields.UserId), "Track under Anonymous consent must not carry userId"); } diff --git a/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs index 548fd79d4..234362ad2 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs @@ -156,9 +156,9 @@ public void PurgeAll_ClearsMemoryAndDisk() { using var queue = new EventQueue(_store, flushIntervalSeconds: 60, flushSize: 100); - queue.Enqueue(new Dictionary { ["type"] = "track", ["eventName"] = "a" }); + queue.Enqueue(new Dictionary { [MessageFields.Type] = "track", [MessageFields.EventName] = "a" }); queue.FlushSync(); - queue.Enqueue(new Dictionary { ["type"] = "track", ["eventName"] = "b" }); + queue.Enqueue(new Dictionary { [MessageFields.Type] = "track", [MessageFields.EventName] = "b" }); queue.PurgeAll(); diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs index 839feb0b6..e2c04407f 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs @@ -69,25 +69,25 @@ public void RoundTripViaSerializer() { var original = new Dictionary { - ["type"] = "track", - ["eventName"] = "progression", - ["properties"] = new Dictionary + [MessageFields.Type] = "track", + [MessageFields.EventName] = "progression", + [MessageFields.Properties] = new Dictionary { ["status"] = "complete", ["score"] = 1500 }, - ["anonymousId"] = "abc", - ["userId"] = "76561198012345" + [MessageFields.AnonymousId] = "abc", + [MessageFields.UserId] = "76561198012345" }; var serialized = Json.Serialize(original); var parsed = JsonReader.DeserializeObject(serialized); - Assert.AreEqual("track", parsed["type"]); - Assert.AreEqual("progression", parsed["eventName"]); - Assert.AreEqual("abc", parsed["anonymousId"]); - Assert.AreEqual("76561198012345", parsed["userId"]); - var props = (Dictionary)parsed["properties"]; + Assert.AreEqual("track", parsed[MessageFields.Type]); + Assert.AreEqual("progression", parsed[MessageFields.EventName]); + Assert.AreEqual("abc", parsed[MessageFields.AnonymousId]); + Assert.AreEqual("76561198012345", parsed[MessageFields.UserId]); + var props = (Dictionary)parsed[MessageFields.Properties]; Assert.AreEqual("complete", props["status"]); Assert.AreEqual(1500, props["score"]); } From 23a482b15724676c75dd3505754e0adbf35a37e3 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 20:45:00 +1000 Subject: [PATCH 11/13] refactor(audience-sdk): introduce EventNames Names the auto-fired and typed event-name strings so runtime emit and the sample-app catalogue read from one place. - Constants.cs: adds EventNames with SessionStart, SessionEnd, SessionHeartbeat, GameLaunch, Progression, Resource, Purchase, MilestoneReached. - Session.cs: SafeTrack calls for session_start / session_end / session_heartbeat read from EventNames. - ImmutableAudience.cs: auto-fired game_launch event reads from EventNames.GameLaunch. - TypedEvents.cs: Progression / Resource / Purchase / MilestoneReached EventName properties read from EventNames. - AudienceSample.Events.cs: typed-event catalogue and the BuildTypedEvent dispatch read from EventNames. --- .../SampleApp/Scripts/AudienceSample.Events.cs | 16 ++++++++-------- .../Audience/Runtime/Core/Constants.cs | 18 ++++++++++++++++++ src/Packages/Audience/Runtime/Core/Session.cs | 8 ++++---- .../Audience/Runtime/Events/TypedEvents.cs | 8 ++++---- .../Audience/Runtime/ImmutableAudience.cs | 2 +- 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index 245240707..f14bc9ba1 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -51,7 +51,7 @@ internal readonly struct EventSpec EventField.Text("platform", optional: true), }), new EventSpec("wishlist_remove", new[] { EventField.Text("gameId") }), - new EventSpec("purchase", new[] { + new EventSpec(EventNames.Purchase, new[] { EventField.Text("currency"), EventField.Number("value"), EventField.Text("itemId", optional: true), @@ -62,7 +62,7 @@ internal readonly struct EventSpec // game_launch is deliberately absent. The Event Reference v1 defines // it as auto-tracked on Init with no public typed class; firing it // from the Send button would double-emit. - new EventSpec("progression", new[] { + new EventSpec(EventNames.Progression, new[] { EventField.Enum("status", new[] { "start", "complete", "fail" }), EventField.Text("world", optional: true), EventField.Text("level", optional: true), @@ -70,14 +70,14 @@ internal readonly struct EventSpec EventField.Number("score", optional: true), EventField.Number("durationSec", optional: true), }), - new EventSpec("resource", new[] { + new EventSpec(EventNames.Resource, new[] { EventField.Enum("flow", new[] { "sink", "source" }), EventField.Text("currency"), EventField.Number("amount"), EventField.Text("itemType", optional: true), EventField.Text("itemId", optional: true), }), - new EventSpec("milestone_reached", new[] { EventField.Text("name") }), + new EventSpec(EventNames.MilestoneReached, new[] { EventField.Text("name") }), new EventSpec("game_page_viewed", new[] { EventField.Text("gameId"), EventField.Text("gameName", optional: true), @@ -99,7 +99,7 @@ internal readonly struct EventSpec { switch (name) { - case "progression": + case EventNames.Progression: return new Progression { Status = ParseProgressionStatus(props), @@ -109,7 +109,7 @@ internal readonly struct EventSpec Score = OptionalInt(props, "score"), DurationSec = OptionalFloat(props, "durationSec"), }; - case "resource": + case EventNames.Resource: return new Resource { Flow = ParseResourceFlow(props), @@ -118,7 +118,7 @@ internal readonly struct EventSpec ItemType = OptionalString(props, "itemType"), ItemId = OptionalString(props, "itemId"), }; - case "purchase": + case EventNames.Purchase: return new Purchase { Currency = OptionalString(props, "currency") ?? "", @@ -128,7 +128,7 @@ internal readonly struct EventSpec Quantity = OptionalInt(props, "quantity"), TransactionId = OptionalString(props, "transactionId"), }; - case "milestone_reached": + case EventNames.MilestoneReached: return new MilestoneReached { Name = OptionalString(props, "name") ?? "" }; default: return null; diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 62189569b..aff166f54 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -76,6 +76,24 @@ internal static class ResponseFields internal const string Rejected = "rejected"; } + // Event names we send on Track. + internal static class EventNames + { + // Session lifecycle (auto-fired) + internal const string SessionStart = "session_start"; + internal const string SessionEnd = "session_end"; + internal const string SessionHeartbeat = "session_heartbeat"; + + // Init lifecycle (auto-fired) + internal const string GameLaunch = "game_launch"; + + // Typed events (IEvent implementations) + internal const string Progression = "progression"; + internal const string Resource = "resource"; + internal const string Purchase = "purchase"; + internal const string MilestoneReached = "milestone_reached"; + } + // Wire-format field names that cross module boundaries inside the SDK // (read by one module, written by another). internal static class MessageFields diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index bf36e7687..b8fa2ab08 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -101,7 +101,7 @@ internal void Start() _heartbeatTimer = new Timer(_ => OnHeartbeat(), null, _heartbeatIntervalMs, _heartbeatIntervalMs); } - SafeTrack("session_start", new Dictionary + SafeTrack(EventNames.SessionStart, new Dictionary { ["sessionId"] = sessionId }); @@ -177,7 +177,7 @@ internal void End() // duration is engagement-aware (excludes pause). Web SDK emits // wall-clock; dashboards should not assume parity. - SafeTrack("session_end", new Dictionary + SafeTrack(EventNames.SessionEnd, new Dictionary { ["sessionId"] = sessionId, ["durationSec"] = duration @@ -202,7 +202,7 @@ internal void EmitEndAndSeal() ResetSessionStateLocked(); } - SafeTrack("session_end", new Dictionary + SafeTrack(EventNames.SessionEnd, new Dictionary { ["sessionId"] = sessionId, ["durationSec"] = duration @@ -250,7 +250,7 @@ internal void OnHeartbeat() ["durationSec"] = duration }; - SafeTrack("session_heartbeat", properties); + SafeTrack(EventNames.SessionHeartbeat, properties); } // Stops exceptions from the track callback from reaching upstream. diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index ccf549137..602d02b21 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -78,7 +78,7 @@ public class Progression : IEvent public float? DurationSec { get; set; } /// - public string EventName => "progression"; + public string EventName => EventNames.Progression; /// public Dictionary ToProperties() @@ -163,7 +163,7 @@ public class Resource : IEvent public string? ItemId { get; set; } /// - public string EventName => "resource"; + public string EventName => EventNames.Resource; /// public Dictionary ToProperties() @@ -227,7 +227,7 @@ public class Purchase : IEvent public string? TransactionId { get; set; } /// - public string EventName => "purchase"; + public string EventName => EventNames.Purchase; // Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build. private static bool IsIso4217(string s) @@ -278,7 +278,7 @@ public class MilestoneReached : IEvent public string? Name { get; set; } /// - public string EventName => "milestone_reached"; + public string EventName => EventNames.MilestoneReached; /// public Dictionary ToProperties() diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 44cafb445..280ab6c49 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -1020,7 +1020,7 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt // No sessionId on game_launch per Event Reference. Pipeline correlates // via eventTimestamp with the session_start that fires just before. - Track("game_launch", properties.Count > 0 ? properties : null); + Track(EventNames.GameLaunch, properties.Count > 0 ? properties : null); } } } From d6de06fe6b1650ba49649d0ad50cb29e5d43b271 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 21:00:00 +1000 Subject: [PATCH 12/13] refactor(audience-sdk): introduce EventPropertyKeys Names the per-event property dictionary keys used by Session, TypedEvents, and the sample-app catalogue. - Constants.cs: adds EventPropertyKeys grouped by owning event (shared keys, Progression, Resource, Purchase, MilestoneReached). - Session.cs: SessionStart / SessionEnd / SessionHeartbeat property dictionaries read keys from EventPropertyKeys. - TypedEvents.cs: Progression / Resource / Purchase / MilestoneReached ToProperties dictionaries read keys from EventPropertyKeys. - AudienceSample.Events.cs: typed event-spec EventField names read from EventPropertyKeys. --- .../Scripts/AudienceSample.Events.cs | 36 +++++++++---------- .../Audience/Runtime/Core/Constants.cs | 32 +++++++++++++++++ src/Packages/Audience/Runtime/Core/Session.cs | 14 ++++---- .../Audience/Runtime/Events/TypedEvents.cs | 36 +++++++++---------- 4 files changed, 75 insertions(+), 43 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index f14bc9ba1..301661e8d 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -52,32 +52,32 @@ internal readonly struct EventSpec }), new EventSpec("wishlist_remove", new[] { EventField.Text("gameId") }), new EventSpec(EventNames.Purchase, new[] { - EventField.Text("currency"), - EventField.Number("value"), - EventField.Text("itemId", optional: true), - EventField.Text("itemName", optional: true), - EventField.Number("quantity", optional: true), - EventField.Text("transactionId", optional: true), + EventField.Text(EventPropertyKeys.Currency), + EventField.Number(EventPropertyKeys.Value), + EventField.Text(EventPropertyKeys.ItemId, optional: true), + EventField.Text(EventPropertyKeys.ItemName, optional: true), + EventField.Number(EventPropertyKeys.Quantity, optional: true), + EventField.Text(EventPropertyKeys.TransactionId, optional: true), }), // game_launch is deliberately absent. The Event Reference v1 defines // it as auto-tracked on Init with no public typed class; firing it // from the Send button would double-emit. new EventSpec(EventNames.Progression, new[] { - EventField.Enum("status", new[] { "start", "complete", "fail" }), - EventField.Text("world", optional: true), - EventField.Text("level", optional: true), - EventField.Text("stage", optional: true), - EventField.Number("score", optional: true), - EventField.Number("durationSec", optional: true), + EventField.Enum(EventPropertyKeys.Status, new[] { "start", "complete", "fail" }), + EventField.Text(EventPropertyKeys.World, optional: true), + EventField.Text(EventPropertyKeys.Level, optional: true), + EventField.Text(EventPropertyKeys.Stage, optional: true), + EventField.Number(EventPropertyKeys.Score, optional: true), + EventField.Number(EventPropertyKeys.DurationSec, optional: true), }), new EventSpec(EventNames.Resource, new[] { - EventField.Enum("flow", new[] { "sink", "source" }), - EventField.Text("currency"), - EventField.Number("amount"), - EventField.Text("itemType", optional: true), - EventField.Text("itemId", optional: true), + EventField.Enum(EventPropertyKeys.Flow, new[] { "sink", "source" }), + EventField.Text(EventPropertyKeys.Currency), + EventField.Number(EventPropertyKeys.Amount), + EventField.Text(EventPropertyKeys.ItemType, optional: true), + EventField.Text(EventPropertyKeys.ItemId, optional: true), }), - new EventSpec(EventNames.MilestoneReached, new[] { EventField.Text("name") }), + new EventSpec(EventNames.MilestoneReached, new[] { EventField.Text(EventPropertyKeys.Name) }), new EventSpec("game_page_viewed", new[] { EventField.Text("gameId"), EventField.Text("gameName", optional: true), diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index aff166f54..69f92457b 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -76,6 +76,38 @@ internal static class ResponseFields internal const string Rejected = "rejected"; } + // Keys inside each event's "properties" dict. + internal static class EventPropertyKeys + { + // Shared across multiple events (Session + Progression for DurationSec, + // Resource + Purchase for Currency / ItemId). + internal const string SessionId = "sessionId"; + internal const string DurationSec = "durationSec"; + internal const string Currency = "currency"; + internal const string ItemId = "itemId"; + + // Progression-specific + internal const string Status = "status"; + internal const string World = "world"; + internal const string Level = "level"; + internal const string Stage = "stage"; + internal const string Score = "score"; + + // Resource-specific + internal const string Flow = "flow"; + internal const string Amount = "amount"; + internal const string ItemType = "itemType"; + + // Purchase-specific + internal const string Value = "value"; + internal const string ItemName = "itemName"; + internal const string Quantity = "quantity"; + internal const string TransactionId = "transactionId"; + + // MilestoneReached-specific + internal const string Name = "name"; + } + // Event names we send on Track. internal static class EventNames { diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index b8fa2ab08..603cd25b0 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -103,7 +103,7 @@ internal void Start() SafeTrack(EventNames.SessionStart, new Dictionary { - ["sessionId"] = sessionId + [EventPropertyKeys.SessionId] = sessionId }); } @@ -179,8 +179,8 @@ internal void End() // wall-clock; dashboards should not assume parity. SafeTrack(EventNames.SessionEnd, new Dictionary { - ["sessionId"] = sessionId, - ["durationSec"] = duration + [EventPropertyKeys.SessionId] = sessionId, + [EventPropertyKeys.DurationSec] = duration }); } @@ -204,8 +204,8 @@ internal void EmitEndAndSeal() SafeTrack(EventNames.SessionEnd, new Dictionary { - ["sessionId"] = sessionId, - ["durationSec"] = duration + [EventPropertyKeys.SessionId] = sessionId, + [EventPropertyKeys.DurationSec] = duration }); } @@ -246,8 +246,8 @@ internal void OnHeartbeat() // Build outside _lock so track doesn't re-enter. var properties = new Dictionary { - ["sessionId"] = sessionId, - ["durationSec"] = duration + [EventPropertyKeys.SessionId] = sessionId, + [EventPropertyKeys.DurationSec] = duration }; SafeTrack(EventNames.SessionHeartbeat, properties); diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index 602d02b21..a946d7400 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -88,14 +88,14 @@ public Dictionary ToProperties() var props = new Dictionary { - ["status"] = Status.Value.ToLowercaseString() + [EventPropertyKeys.Status] = Status.Value.ToLowercaseString() }; - if (World != null) props["world"] = World; - if (Level != null) props["level"] = Level; - if (Stage != null) props["stage"] = Stage; - if (Score.HasValue) props["score"] = Score.Value; - if (DurationSec.HasValue) props["durationSec"] = DurationSec.Value; + if (World != null) props[EventPropertyKeys.World] = World; + if (Level != null) props[EventPropertyKeys.Level] = Level; + if (Stage != null) props[EventPropertyKeys.Stage] = Stage; + if (Score.HasValue) props[EventPropertyKeys.Score] = Score.Value; + if (DurationSec.HasValue) props[EventPropertyKeys.DurationSec] = DurationSec.Value; return props; } @@ -177,13 +177,13 @@ public Dictionary ToProperties() var props = new Dictionary { - ["flow"] = Flow.Value.ToLowercaseString(), - ["currency"] = Currency, - ["amount"] = Amount.Value + [EventPropertyKeys.Flow] = Flow.Value.ToLowercaseString(), + [EventPropertyKeys.Currency] = Currency, + [EventPropertyKeys.Amount] = Amount.Value }; - if (ItemType != null) props["itemType"] = ItemType; - if (ItemId != null) props["itemId"] = ItemId; + if (ItemType != null) props[EventPropertyKeys.ItemType] = ItemType; + if (ItemId != null) props[EventPropertyKeys.ItemId] = ItemId; return props; } @@ -252,14 +252,14 @@ public Dictionary ToProperties() var props = new Dictionary { - ["currency"] = Currency, - ["value"] = Value.Value + [EventPropertyKeys.Currency] = Currency, + [EventPropertyKeys.Value] = Value.Value }; - if (ItemId != null) props["itemId"] = ItemId; - if (ItemName != null) props["itemName"] = ItemName; - if (Quantity.HasValue) props["quantity"] = Quantity.Value; - if (TransactionId != null) props["transactionId"] = TransactionId; + if (ItemId != null) props[EventPropertyKeys.ItemId] = ItemId; + if (ItemName != null) props[EventPropertyKeys.ItemName] = ItemName; + if (Quantity.HasValue) props[EventPropertyKeys.Quantity] = Quantity.Value; + if (TransactionId != null) props[EventPropertyKeys.TransactionId] = TransactionId; return props; } @@ -288,7 +288,7 @@ public Dictionary ToProperties() return new Dictionary { - ["name"] = Name + [EventPropertyKeys.Name] = Name }; } } From 994279f8c83340237730cef471f41f61bbb62986 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 1 May 2026 21:15:00 +1000 Subject: [PATCH 13/13] refactor(audience-sdk): introduce ContextKeys and GameLaunchPropertyKeys Names the per-event context dictionary keys and the auto-fired game_launch property keys, and routes DeviceCollector's twelve truncation sites through Constants.MaxFieldLength so the 256-char cap reads as a contract. - Constants.cs: adds ContextKeys (UserAgent, Timezone, Locale, Screen) and GameLaunchPropertyKeys (Platform, Version, BuildGuid, UnityVersion, OsFamily, DeviceModel, Gpu, GpuVendor, Cpu, CpuCores, RamMb, ScreenDpi, DistributionPlatform). - DeviceCollector.cs: CollectContext writes ContextKeys; CollectGameLaunchProperties writes GameLaunchPropertyKeys; truncation calls use Constants.MaxFieldLength. - ImmutableAudience.cs: distribution-platform overlay on game_launch reads GameLaunchPropertyKeys.DistributionPlatform. --- .../Audience/Runtime/Core/Constants.cs | 27 ++++++++++++++++ .../Audience/Runtime/ImmutableAudience.cs | 2 +- .../Audience/Runtime/Unity/DeviceCollector.cs | 32 +++++++++---------- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 69f92457b..a8fe1eebc 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -62,6 +62,33 @@ internal static class MessageTypes internal const string Alias = "alias"; } + // Property keys for the auto-fired game_launch event. + internal static class GameLaunchPropertyKeys + { + internal const string Platform = "platform"; + internal const string Version = "version"; + internal const string BuildGuid = "buildGuid"; + internal const string UnityVersion = "unityVersion"; + internal const string OsFamily = "osFamily"; + internal const string DeviceModel = "deviceModel"; + internal const string Gpu = "gpu"; + internal const string GpuVendor = "gpuVendor"; + internal const string Cpu = "cpu"; + internal const string CpuCores = "cpuCores"; + internal const string RamMb = "ramMb"; + internal const string ScreenDpi = "screenDpi"; + internal const string DistributionPlatform = "distributionPlatform"; + } + + // Keys merged into every event's context dictionary. + internal static class ContextKeys + { + internal const string UserAgent = "userAgent"; + internal const string Timezone = "timezone"; + internal const string Locale = "locale"; + internal const string Screen = "screen"; + } + // JSON keys for the consent-sync PUT body. internal static class ConsentBodyFields { diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 280ab6c49..c9fc1a542 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -1016,7 +1016,7 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt // Config-supplied distributionPlatform overrides the provider value. if (config.DistributionPlatform != null) - properties["distributionPlatform"] = config.DistributionPlatform; + properties[GameLaunchPropertyKeys.DistributionPlatform] = config.DistributionPlatform; // No sessionId on game_launch per Event Reference. Pipeline correlates // via eventTimestamp with the session_start that fires just before. diff --git a/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs b/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs index 62be7dfa8..18588a964 100644 --- a/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs +++ b/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs @@ -14,17 +14,17 @@ internal static Dictionary CollectContext() // 256-char cap mirrors Web SDK's identifier truncation. var ctx = new Dictionary { - ["userAgent"] = Truncate(SystemInfo.operatingSystem, 256), + [ContextKeys.UserAgent] = Truncate(SystemInfo.operatingSystem, Constants.MaxFieldLength), }; var timezone = SafeTimezone(); - if (timezone != null) ctx["timezone"] = Truncate(timezone, 256); + if (timezone != null) ctx[ContextKeys.Timezone] = Truncate(timezone, Constants.MaxFieldLength); var locale = LocaleString(); - if (locale != null) ctx["locale"] = Truncate(locale, 256); + if (locale != null) ctx[ContextKeys.Locale] = Truncate(locale, Constants.MaxFieldLength); var screen = TryResolveScreenString(); - if (screen != null) ctx["screen"] = Truncate(screen, 256); + if (screen != null) ctx[ContextKeys.Screen] = Truncate(screen, Constants.MaxFieldLength); return ctx; } @@ -49,22 +49,22 @@ internal static Dictionary CollectGameLaunchProperties() { var props = new Dictionary { - ["platform"] = Application.platform.ToString(), - ["version"] = Truncate(Application.version, 256), - ["buildGuid"] = Truncate(Application.buildGUID, 256), - ["unityVersion"] = Truncate(Application.unityVersion, 256), - ["osFamily"] = SystemInfo.operatingSystemFamily.ToString(), - ["deviceModel"] = Truncate(SystemInfo.deviceModel, 256), - ["gpu"] = Truncate(SystemInfo.graphicsDeviceName, 256), - ["gpuVendor"] = Truncate(SystemInfo.graphicsDeviceVendor, 256), - ["cpu"] = Truncate(SystemInfo.processorType, 256), - ["cpuCores"] = SystemInfo.processorCount, - ["ramMb"] = SystemInfo.systemMemorySize, + [GameLaunchPropertyKeys.Platform] = Application.platform.ToString(), + [GameLaunchPropertyKeys.Version] = Truncate(Application.version, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.BuildGuid] = Truncate(Application.buildGUID, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.UnityVersion] = Truncate(Application.unityVersion, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.OsFamily] = SystemInfo.operatingSystemFamily.ToString(), + [GameLaunchPropertyKeys.DeviceModel] = Truncate(SystemInfo.deviceModel, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.Gpu] = Truncate(SystemInfo.graphicsDeviceName, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.GpuVendor] = Truncate(SystemInfo.graphicsDeviceVendor, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.Cpu] = Truncate(SystemInfo.processorType, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.CpuCores] = SystemInfo.processorCount, + [GameLaunchPropertyKeys.RamMb] = SystemInfo.systemMemorySize, }; // Screen.dpi can be 0 on some Linux WMs. var dpi = (int)Screen.dpi; - if (dpi > 0) props["screenDpi"] = dpi; + if (dpi > 0) props[GameLaunchPropertyKeys.ScreenDpi] = dpi; return props; }