Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/Packages/Audience/Runtime/AudienceConfig.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
namespace Immutable.Audience
{
/// <summary>Configuration passed to <see cref="ImmutableAudience.Init"/>.</summary>
// Configuration passed to ImmutableAudience.Init.
public class AudienceConfig
{
// Studio API key.
public string PublishableKey { get; set; }

// Initial consent level.
public ConsentLevel Consent { get; set; } = ConsentLevel.None;
/// <summary>
/// Distribution platform the game is running on.
/// Use <see cref="DistributionPlatforms"/> for common values, or pass any custom string.
/// </summary>

// Distribution platform the game is running on.
public string DistributionPlatform { get; set; }

// Enable debug logging.
public bool Debug { get; set; } = false;

// How often pending events are flushed to the backend.
public int FlushIntervalSeconds { get; set; } = Constants.DefaultFlushIntervalSeconds;

// Flush as soon as this many events are queued.
public int FlushSize { get; set; } = Constants.DefaultFlushSize;
}
}
8 changes: 4 additions & 4 deletions src/Packages/Audience/Runtime/ConsentLevel.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
namespace Immutable.Audience
{
/// <summary>Controls what the Audience SDK tracks.</summary>
// How much data the Audience SDK is allowed to collect.
public enum ConsentLevel
{
/// <summary>SDK inert. No events queued or sent. No IDs persisted to disk.</summary>
// No tracking.
None,
/// <summary>Track events with anonymousId only. Identify/Alias discarded with warning.</summary>
// Anonymous tracking only.
Anonymous,
/// <summary>All events. Identify/Alias send. userId attached to track events.</summary>
// Full tracking, including identity.
Full
}
}
24 changes: 24 additions & 0 deletions src/Packages/Audience/Runtime/Core/AudiencePaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.IO;

namespace Immutable.Audience
{
internal static class AudiencePaths
{
private const string RootDirName = "imtbl_audience";
private const string IdentityFileName = "identity";
private const string ConsentFileName = "consent";
private const string QueueDirName = "queue";

internal static string AudienceDir(string persistentDataPath) =>
Path.Combine(persistentDataPath, RootDirName);

internal static string IdentityFile(string persistentDataPath) =>
Path.Combine(AudienceDir(persistentDataPath), IdentityFileName);

internal static string ConsentFile(string persistentDataPath) =>
Path.Combine(AudienceDir(persistentDataPath), ConsentFileName);

internal static string QueueDir(string persistentDataPath) =>
Path.Combine(AudienceDir(persistentDataPath), QueueDirName);
}
}
5 changes: 1 addition & 4 deletions src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ internal static string BaseUrl(string publishableKey) =>
: ProductionBaseUrl;
}

/// <summary>
/// String constants for common game distribution platforms.
/// Any string is accepted -- studios are not limited to these values.
/// </summary>
// Common distribution platform values for AudienceConfig.DistributionPlatform.
public static class DistributionPlatforms
{
public const string Steam = "steam";
Expand Down
12 changes: 3 additions & 9 deletions src/Packages/Audience/Runtime/Core/Identity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ internal sealed class Identity
private static volatile string _cachedId;
private static readonly object _sync = new object();

private static string GetDirectory(string persistentDataPath) =>
Path.Combine(persistentDataPath, "imtbl_audience");

private static string GetFilePath(string persistentDataPath) =>
Path.Combine(GetDirectory(persistentDataPath), "identity");

// Returns the anonymous ID, generating and persisting it on first call.
// Returns null without touching disk when consent is None.
// Safe to call from any thread after ImmutableAudience.Init() has run on the main thread.
Expand All @@ -41,10 +35,10 @@ internal static string GetOrCreate(string persistentDataPath, ConsentLevel conse
if (_cachedId != null)
return _cachedId;

var dir = GetDirectory(persistentDataPath);
var dir = AudiencePaths.AudienceDir(persistentDataPath);
Directory.CreateDirectory(dir); // no-op if already exists

var filePath = GetFilePath(persistentDataPath);
var filePath = AudiencePaths.IdentityFile(persistentDataPath);

// Returning player — read the ID we wrote on a previous launch.
if (File.Exists(filePath))
Expand Down Expand Up @@ -85,7 +79,7 @@ internal static void Reset(string persistentDataPath)
{
_cachedId = null;

var filePath = GetFilePath(persistentDataPath);
var filePath = AudiencePaths.IdentityFile(persistentDataPath);
try
{
File.Delete(filePath);
Expand Down
7 changes: 2 additions & 5 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
namespace Immutable.Audience
{
/// <summary>
/// Entry point for the Immutable Audience SDK.
/// Call <see cref="Init"/> once on startup, then use the static methods from any thread.
/// </summary>
// Entry point for the Immutable Audience SDK.
public static class ImmutableAudience
{
// Scaffold only -- implementation follows in subsequent sub-issues (see SDK-99).

/// <summary>Initialise the SDK. Call once, typically in your game's entry scene.</summary>
// Starts the SDK. Call once at launch.
public static void Init(AudienceConfig config)
{
throw new System.NotImplementedException(
Expand Down
18 changes: 7 additions & 11 deletions src/Packages/Audience/Runtime/Transport/DiskStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@

namespace Immutable.Audience
{
/// <summary>
/// File-per-event persistent store. Each event is written as an atomic
/// <c>{ticks}_{uuid}.json</c> file inside <c>imtbl_audience/queue/</c>.
/// </summary>
// File-per-event persistent store. Each event is written as an atomic
// {ticks}_{uuid}.json file inside imtbl_audience/queue/.
internal sealed class DiskStore
{
private readonly string _queueDir;
Expand All @@ -19,7 +17,7 @@ internal DiskStore(string persistentDataPath)
Directory.CreateDirectory(_queueDir);
}

/// <summary>Atomically writes <paramref name="json"/> as a new event file.</summary>
// Atomically writes json as a new event file.
internal void Write(string json)
{
var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}.json";
Expand All @@ -40,10 +38,8 @@ internal void Write(string json)
}
}

/// <summary>
/// Returns up to <paramref name="maxSize"/> file paths, oldest first.
/// Files older than <see cref="Constants.StaleEventDays"/> days are deleted and excluded.
/// </summary>
// Returns up to maxSize file paths, oldest first. Stale files
// (older than Constants.StaleEventDays) are deleted and excluded.
internal IReadOnlyList<string> ReadBatch(int maxSize)
{
if (maxSize <= 0)
Expand Down Expand Up @@ -83,14 +79,14 @@ internal IReadOnlyList<string> ReadBatch(int maxSize)
return result;
}

/// <summary>Deletes the event files at <paramref name="paths"/>.</summary>
// Deletes the given event files.
internal void Delete(IEnumerable<string> paths)
{
foreach (var path in paths)
TryDelete(path);
}

/// <summary>Returns the total number of event files currently on disk.</summary>
// Total number of event files currently on disk.
internal int Count() => Directory.GetFiles(_queueDir, "*.json").Length;

private static void TryDelete(string path)
Expand Down
36 changes: 13 additions & 23 deletions src/Packages/Audience/Runtime/Transport/EventQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,11 @@

namespace Immutable.Audience
{
/// <summary>
/// Thread-safe, disk-persistent batch event queue for the Audience SDK.
///
/// <para>Enqueue is lock-free and safe to call from any thread. A background
/// drain thread moves events from the in-memory <see cref="ConcurrentQueue{T}"/>
/// to <see cref="DiskStore"/>, flushing either on a time interval or when the
/// in-memory batch reaches <see cref="AudienceConfig.FlushSize"/>.</para>
///
/// <para>Call <see cref="Shutdown"/> before process exit to flush remaining events
/// and stop the drain thread cleanly.</para>
/// </summary>
// Thread-safe, disk-persistent batch event queue.
// Enqueue is lock-free and safe from any thread. A background drain
// thread moves events from the in-memory ConcurrentQueue to DiskStore,
// flushing on a time interval or when the batch reaches FlushSize.
// Call Shutdown before process exit.
internal sealed class EventQueue : IDisposable
{
private readonly DiskStore _store;
Expand All @@ -29,9 +23,9 @@ internal sealed class EventQueue : IDisposable
// Volatile so all threads see the shutdown signal immediately.
private volatile bool _disposed;

/// <param name="store">Pre-created <see cref="DiskStore"/> for this queue.</param>
/// <param name="flushIntervalSeconds">How often to drain to disk regardless of batch size.</param>
/// <param name="flushSize">Drain to disk immediately when this many events are queued.</param>
// store: destination for drained events.
// flushIntervalSeconds: how often to drain to disk regardless of batch size.
// flushSize: drain to disk immediately when this many events are queued.
internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
Expand All @@ -46,7 +40,7 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize)
_drainThread.Start();
}

/// <summary>Enqueues a JSON-serialised event. Lock-free; safe from any thread.</summary>
// Enqueues a JSON-serialised event. Lock-free; safe from any thread.
internal void Enqueue(string json)
{
if (_disposed) return;
Expand All @@ -58,19 +52,15 @@ internal void Enqueue(string json)
_flushGate.Set();
}

/// <summary>
/// Drains the in-memory queue and persists all events to disk immediately.
/// Blocks until the drain is complete.
/// </summary>
// Drains the in-memory queue and persists all events to disk
// immediately. Blocks until the drain is complete.
internal void FlushSync()
{
DrainMemoryToDisk();
}

/// <summary>
/// Flushes all pending events to disk and stops the drain thread.
/// Safe to call multiple times.
/// </summary>
// Flushes all pending events to disk and stops the drain thread.
// Safe to call multiple times.
internal void Shutdown()
{
if (_disposed) return;
Expand Down
48 changes: 18 additions & 30 deletions src/Packages/Audience/Runtime/Transport/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@

namespace Immutable.Audience
{
/// <summary>
/// Sends queued events from <see cref="DiskStore"/> to the Audience backend.
/// </summary>
// Sends queued events from DiskStore to the Audience backend.
internal sealed class HttpTransport : IDisposable
{
private readonly DiskStore _store;
Expand All @@ -27,11 +25,10 @@ internal sealed class HttpTransport : IDisposable
private int _consecutiveFailures;
private DateTime? _nextAttemptAt;

/// <param name="store">Source of event batches.</param>
/// <param name="publishableKey">Studio API key. Sent as <c>x-immutable-publishable-key</c> on every request.</param>
/// <param name="onError">Optional failure callback. Exceptions thrown inside it are caught and ignored.</param>
/// <param name="handler">Optional <see cref="HttpMessageHandler"/>. Callers can supply a custom pipeline (e.g. specific for test purposes). Defaults to the standard handler when null.</param>
/// <param name="getUtcNow">Optional UTC clock source used for backoff timing (e.g. swappable for deterministic time). Defaults to <c>DateTime.UtcNow</c> when null.</param>
// store: source of event batches.
// publishableKey: sent as x-immutable-publishable-key on every request.
// onError: optional failure callback. Exceptions thrown inside it are caught.
// handler / getUtcNow: test seams; null for production use.
internal HttpTransport(
DiskStore store,
string publishableKey,
Expand All @@ -48,10 +45,8 @@ internal HttpTransport(
_getUtcNow = getUtcNow ?? (() => DateTime.UtcNow);
}

/// <summary>
/// Attempts to process one batch: reads it from disk, gzips it, and POSTs it.
/// Returns true if a batch was consumed (outcome irrelevant), false if the queue was empty.
/// </summary>
// Processes one batch. Returns true if a batch was consumed
// (outcome irrelevant), false if the queue was empty.
internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
{
var batch = _store.ReadBatch(Constants.DefaultFlushSize);
Expand Down Expand Up @@ -80,15 +75,18 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
return true;
}

var compressed = Gzip.Compress(payload);

try
{
using var request = new HttpRequestMessage(HttpMethod.Post, _url);
request.Headers.Add("x-immutable-publishable-key", _publishableKey);
#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");
#else
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
#endif

using var response = await _client.SendAsync(request, ct).ConfigureAwait(false);

Expand Down Expand Up @@ -143,16 +141,11 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
_ => 60_000,
};

/// <summary>
/// Earliest UTC time at which the next attempt may run.
/// Null when no backoff is active (never failed, or last attempt succeeded).
/// </summary>
// Earliest UTC time at which the next attempt may run.
// Null when no backoff is active.
internal DateTime? NextAttemptAt => _nextAttemptAt;

/// <summary>
/// True while <c>UtcNow &lt; NextAttemptAt</c>. Flips false as the clock
/// advances; no reset required.
/// </summary>
// True while UtcNow < NextAttemptAt. Flips false as the clock advances.
internal bool IsInBackoffWindow => _getUtcNow() < _nextAttemptAt;

public void Dispose()
Expand All @@ -175,14 +168,9 @@ private void ResetBackoff()
_nextAttemptAt = null;
}

/// <summary>
/// Reads each path and wraps the concatenated JSON bodies in
/// <c>{"batch":[msg1,msg2,...]}</c>.
/// </summary>
/// <returns>
/// The batched JSON, or <c>null</c> if every path was unreadable. Caller
/// treats <c>null</c> as "nothing to send" and deletes the path list.
/// </returns>
// Reads each path and wraps the concatenated JSON bodies in
// {"batch":[msg1,msg2,...]}. Returns null if every path was
// unreadable; the caller treats null as "nothing to send".
private static string? BuildPayload(IReadOnlyList<string> paths)
{
var sb = new StringBuilder("{\"batch\":[");
Expand Down
10 changes: 5 additions & 5 deletions src/Packages/Audience/Runtime/Utility/Gzip.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
#if IMMUTABLE_AUDIENCE_GZIP
using System.IO;
using System.IO.Compression;
using System.Text;

namespace Immutable.Audience
{
/// <summary>
/// Gzip compression using <see cref="GZipStream"/> from System.IO.Compression.
/// Available in Unity 2021+ (.NET Standard 2.1). Pure C#, works on all desktop platforms.
/// </summary>
// Gzip compression via GZipStream from System.IO.Compression.
// Available in Unity 2021+ (.NET Standard 2.1). Works on all desktop platforms.
internal static class Gzip
{
internal static byte[] Compress(string text)
Expand All @@ -23,4 +22,5 @@ internal static byte[] Compress(string text)
return output.ToArray();
}
}
}
}
#endif
Loading
Loading