diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfcc72..a2f9867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.5 +- Reduced memory allocations by utilizing ListPool and DictionaryPool +- Flush method properly returns a cancellable Task +- CancellationToken handled all the way through to the WebRequest +- Improved error handling and logging + ## 0.2.4 - Fixed memory leak with event errors diff --git a/README.md b/README.md index d58f772..3e05529 100644 --- a/README.md +++ b/README.md @@ -48,4 +48,4 @@ A few important notes: ## Preparing for Submission to Apple App Store -When submitting your app to the Apple App Store, you'll need to fill out the `App Privacy` form. You can find all the answers on our [How to fill out the Apple App Privacy when using Aptabase](https://aptabase.com/docs/apple-app-privacy) guide. +When submitting your app to the Apple App Store, you'll need to fill out the `App Privacy` form. You can find all the answers on our [How to fill out the Apple App Privacy when using Aptabase](https://aptabase.com/docs/apple-app-privacy) guide. \ No newline at end of file diff --git a/Runtime/Aptabase.cs b/Runtime/Aptabase.cs index fc9e39d..8600b9e 100644 --- a/Runtime/Aptabase.cs +++ b/Runtime/Aptabase.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using UnityEngine; +using UnityEngine.Pool; using Random = UnityEngine.Random; namespace AptabaseSDK @@ -13,70 +14,72 @@ public static class Aptabase private static IDispatcher _dispatcher; private static EnvironmentInfo _env; private static Settings _settings; - + private static DateTime _lastTouched = DateTime.UtcNow; private static string _baseURL; - + private static readonly TimeSpan _sessionTimeout = TimeSpan.FromMinutes(60); + private static readonly Dictionary _hosts = new() { { "US", "https://us.aptabase.com" }, { "EU", "https://eu.aptabase.com" }, { "DEV", "http://localhost:3000" }, - { "SH", "" }, + { "SH", "" } }; - + private static int _flushTimer; private static CancellationTokenSource _pollingCancellationTokenSource; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Initialize() { - //load settings + // load settings _settings = Resources.Load("AptabaseSettings"); if (_settings == null) { - Debug.LogWarning("Aptabase Settings not found. Tracking will be disabled"); + Debug.LogError("[AptabaseAnalytics] Aptabase Settings not found. Tracking will be disabled"); return; } - + var key = _settings.AppKey; - + var parts = key.Split("-"); if (parts.Length != 3 || !_hosts.ContainsKey(parts[1])) { - Debug.LogWarning($"The Aptabase App Key {key} is invalid. Tracking will be disabled"); + Debug.LogError($"[AptabaseAnalytics] The Aptabase App Key {key} is invalid. Tracking will be disabled"); return; } _env = Environment.GetEnvironmentInfo(Version.GetVersionInfo(_settings)); - _baseURL = GetBaseUrl(parts[1]); - - #if UNITY_WEBGL - _dispatcher = new WebGLDispatcher(_settings.AppKey, _baseURL, _env); - #else - _dispatcher = new Dispatcher(_settings.AppKey, _baseURL, _env); - #endif - - //create listener + if (string.IsNullOrEmpty(_baseURL)) + return; + +#if UNITY_WEBGL + _dispatcher = new WebGLDispatcher(_settings.AppKey, _baseURL, _env); +#else + _dispatcher = new Dispatcher(_settings.AppKey, _baseURL, _env); +#endif + + // create listener var eventFocusHandler = new GameObject("AptabaseService"); eventFocusHandler.AddComponent(); } - - private static async void StartPolling(int flushTimer) + + private static async Task StartPolling(int flushTimer) { StopPolling(); _flushTimer = flushTimer; _pollingCancellationTokenSource = new CancellationTokenSource(); - + while (_pollingCancellationTokenSource is { IsCancellationRequested: false }) { try { await Task.Delay(_flushTimer, _pollingCancellationTokenSource.Token); - Flush(); + await Flush(); } catch (TaskCanceledException) { @@ -89,23 +92,16 @@ private static void StopPolling() { if (_flushTimer <= 0) return; - + _pollingCancellationTokenSource?.Cancel(); + _pollingCancellationTokenSource?.Dispose(); _pollingCancellationTokenSource = null; _flushTimer = 0; } - + public static void OnApplicationFocus(bool hasFocus) { - if (hasFocus) - { - StartPolling(GetFlushInterval()); - } - else - { - Flush(); - StopPolling(); - } + _ = hasFocus ? StartPolling(GetFlushInterval()) : Flush().ContinueWith(_ => StopPolling()); } private static string EvalSessionId() @@ -118,14 +114,15 @@ private static string EvalSessionId() _lastTouched = now; return _sessionId; } - + private static string GetBaseUrl(string region) { if (region == "SH") { if (string.IsNullOrEmpty(_settings.SelfHostURL)) { - Debug.LogWarning("Host parameter must be defined when using Self-Hosted App Key. Tracking will be disabled."); + Debug.LogWarning( + "[AptabaseAnalytics] Host parameter must be defined when using Self-Hosted App Key. Tracking will be disabled."); return null; } @@ -135,18 +132,22 @@ private static string GetBaseUrl(string region) return _hosts[region]; } - public static void Flush() + public static Task Flush(CancellationToken cancellationToken = default) { - _dispatcher.Flush(); + return _dispatcher.Flush(cancellationToken); } - public static void TrackEvent(string eventName, Dictionary props = null) + public static void TrackEvent(string eventName, Dictionary eventProps = null) { if (string.IsNullOrEmpty(_baseURL)) return; - - props ??= new Dictionary(); - var eventData = new Event() + + var props = DictionaryPool.Get(); + if (eventProps != null) + foreach (var prop in eventProps) + props.Add(prop.Key, prop.Value); + + var eventData = new Event { timestamp = DateTime.UtcNow.ToString("o"), sessionId = EvalSessionId(), @@ -154,7 +155,7 @@ public static void TrackEvent(string eventName, Dictionary props systemProps = _env, props = props }; - + _dispatcher.Enqueue(eventData); } diff --git a/Runtime/Dispatcher/Dispatcher.cs b/Runtime/Dispatcher/Dispatcher.cs index c38ed68..08ccdee 100644 --- a/Runtime/Dispatcher/Dispatcher.cs +++ b/Runtime/Dispatcher/Dispatcher.cs @@ -1,86 +1,91 @@ using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using AptabaseSDK.TinyJson; using UnityEngine; +using UnityEngine.Pool; namespace AptabaseSDK { - public class Dispatcher: IDispatcher + public class Dispatcher : IDispatcher { private const string EVENTS_ENDPOINT = "/api/v0/events"; - + private const int MAX_BATCH_SIZE = 25; - - private static string _apiURL; - private static WebRequestHelper _webRequestHelper; - private static string _appKey; - private static EnvironmentInfo _environment; - - private bool _flushInProgress; + private readonly Queue _events; - + private readonly List _failedEvents; + private readonly WebRequestHelper _webRequestHelper; + + private bool _flushInProgress; + public Dispatcher(string appKey, string baseURL, EnvironmentInfo env) { - //create event queue + // create the event queue _events = new Queue(); - - //web request setup information - _apiURL = $"{baseURL}{EVENTS_ENDPOINT}"; - _appKey = appKey; - _environment = env; - _webRequestHelper = new WebRequestHelper(); + _failedEvents = new List(10); + + // web request setup information + _webRequestHelper = new WebRequestHelper($"{baseURL}{EVENTS_ENDPOINT}", appKey, env); } - + public void Enqueue(Event data) { _events.Enqueue(data); } - - private void Enqueue(List data) - { - foreach (var eventData in data) - _events.Enqueue(eventData); - } - public async void Flush() + public async Task Flush(CancellationToken cancellationToken) { if (_flushInProgress || _events.Count <= 0) return; _flushInProgress = true; - var failedEvents = new List(); - - //flush all events + _failedEvents.Clear(); + + // flush all events do { - var eventsCount = Mathf.Min(MAX_BATCH_SIZE, _events.Count); - var eventsToSend = new List(); - for (var i = 0; i < eventsCount; i++) - eventsToSend.Add(_events.Dequeue()); + var eventsToSend = ListPool.Get(); try - { - var result = await SendEvents(eventsToSend); - if (!result) failedEvents.AddRange(eventsToSend); + { + var eventsCount = Mathf.Min(MAX_BATCH_SIZE, _events.Count); + for (var i = 0; i < eventsCount; i++) + eventsToSend.Add(_events.Dequeue()); + + var result = await SendEvents(eventsToSend, cancellationToken); + if (!result) + _failedEvents.AddRange(eventsToSend); } catch { - failedEvents.AddRange(eventsToSend); + _failedEvents.AddRange(eventsToSend); } + finally + { + foreach (var evt in eventsToSend.Except(_failedEvents)) + DictionaryPool.Release(evt.props); - } while (_events.Count > 0); - - if (failedEvents.Count > 0) - Enqueue(failedEvents); + ListPool.Release(eventsToSend); + } + } while (_events.Count > 0 && !cancellationToken.IsCancellationRequested); + + if (_failedEvents.Count > 0) + Enqueue(_failedEvents); _flushInProgress = false; } - - private static async Task SendEvents(List events) + + private void Enqueue(List data) + { + foreach (var eventData in data) + _events.Enqueue(eventData); + } + + private async Task SendEvents(List events, CancellationToken cancellationToken) { - var webRequest = _webRequestHelper.CreateWebRequest(_apiURL, _appKey, _environment, events.ToJson()); - var result = await _webRequestHelper.SendWebRequestAsync(webRequest); - return result; + return await _webRequestHelper.CreateAndSendWebRequestAsync(events.ToJson(), cancellationToken); } } } \ No newline at end of file diff --git a/Runtime/Dispatcher/IDispatcher.cs b/Runtime/Dispatcher/IDispatcher.cs index c7c34bc..aec5357 100644 --- a/Runtime/Dispatcher/IDispatcher.cs +++ b/Runtime/Dispatcher/IDispatcher.cs @@ -1,9 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; + namespace AptabaseSDK { public interface IDispatcher { - public void Enqueue(Event data); + void Enqueue(Event data); - public void Flush(); + Task Flush(CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Runtime/Dispatcher/WebGLDispatcher.cs b/Runtime/Dispatcher/WebGLDispatcher.cs index 020b830..c75a11d 100644 --- a/Runtime/Dispatcher/WebGLDispatcher.cs +++ b/Runtime/Dispatcher/WebGLDispatcher.cs @@ -1,80 +1,72 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using AptabaseSDK.TinyJson; namespace AptabaseSDK { - public class WebGLDispatcher: IDispatcher + public class WebGLDispatcher : IDispatcher { private const string EVENT_ENDPOINT = "/api/v0/event"; - - private static string _apiURL; - private static WebRequestHelper _webRequestHelper; - private static string _appKey; - private static EnvironmentInfo _environment; - - private bool _flushInProgress; private readonly Queue _events; - + + private readonly WebRequestHelper _webRequestHelper; + + private bool _flushInProgress; + public WebGLDispatcher(string appKey, string baseURL, EnvironmentInfo env) { - //create event queue + // create the event queue _events = new Queue(); - - //web request setup information - _apiURL = $"{baseURL}{EVENT_ENDPOINT}"; - _appKey = appKey; - _environment = env; - _webRequestHelper = new WebRequestHelper(); + + // web request setup information + _webRequestHelper = new WebRequestHelper($"{baseURL}{EVENT_ENDPOINT}", appKey, env); } - + public void Enqueue(Event data) { _events.Enqueue(data); - Flush(); - } - - private void Enqueue(List data) - { - foreach (var eventData in data) - _events.Enqueue(eventData); + _ = Flush(); } - public async void Flush() + public async Task Flush(CancellationToken cancellationToken = default) { if (_flushInProgress || _events.Count <= 0) return; _flushInProgress = true; var failedEvents = new List(); - - //flush all events + + // flush all events do { var eventToSend = _events.Dequeue(); try { - var result = await SendEvent(eventToSend); + var result = await SendEvent(eventToSend, cancellationToken); if (!result) failedEvents.Add(eventToSend); } catch { failedEvents.Add(eventToSend); } + } while (_events.Count > 0 && !cancellationToken.IsCancellationRequested); - } while (_events.Count > 0); - - if (failedEvents.Count > 0) + if (failedEvents.Count > 0) Enqueue(failedEvents); _flushInProgress = false; } - - private static async Task SendEvent(Event eventData) + + private void Enqueue(List data) + { + foreach (var eventData in data) + _events.Enqueue(eventData); + } + + private async Task SendEvent(Event eventData, CancellationToken cancellationToken) { - var webRequest = _webRequestHelper.CreateWebRequest(_apiURL, _appKey, _environment, eventData.ToJson()); - var result = await _webRequestHelper.SendWebRequestAsync(webRequest); - return result; + return await _webRequestHelper.CreateAndSendWebRequestAsync(eventData.ToJson(), cancellationToken); } } } \ No newline at end of file diff --git a/Runtime/Environment.cs b/Runtime/Environment.cs index eb7f242..b2dc5b7 100644 --- a/Runtime/Environment.cs +++ b/Runtime/Environment.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; using UnityEngine; @@ -8,8 +9,8 @@ public static class Environment public static EnvironmentInfo GetEnvironmentInfo(VersionInfo versionInfo) { var os = GetOperatingSystemInfo(); - - return new EnvironmentInfo() + + return new EnvironmentInfo { isDebug = Application.isEditor || Debug.isDebugBuild, locale = CultureInfo.CurrentCulture.Name, @@ -38,6 +39,7 @@ private static OperatingSystemInfo GetOperatingSystemInfo() var trimmedVersion = operatingSystem.osVersion[..index].Trim(); operatingSystem.osVersion = trimmedVersion; } + break; case RuntimePlatform.IPhonePlayer: var model = SystemInfo.deviceModel.ToLower(); @@ -60,6 +62,7 @@ private static OperatingSystemInfo GetOperatingSystemInfo() operatingSystem.osName = Application.platform.ToString(); break; } + return operatingSystem; } } @@ -70,7 +73,7 @@ public struct OperatingSystemInfo public string osVersion; } - public struct EnvironmentInfo + public struct EnvironmentInfo : IEquatable { public bool isDebug; public string locale; @@ -79,5 +82,36 @@ public struct EnvironmentInfo public string osName; public string osVersion; public string sdkVersion; + + public bool Equals(EnvironmentInfo other) + { + return isDebug == other.isDebug + && locale == other.locale + && appVersion == other.appVersion + && appBuildNumber == other.appBuildNumber + && osName == other.osName + && osVersion == other.osVersion + && sdkVersion == other.sdkVersion; + } + + public override bool Equals(object obj) + { + return obj is EnvironmentInfo other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(isDebug, locale, appVersion, appBuildNumber, osName, osVersion, sdkVersion); + } + + public static bool operator ==(EnvironmentInfo left, EnvironmentInfo right) + { + return left.Equals(right); + } + + public static bool operator !=(EnvironmentInfo left, EnvironmentInfo right) + { + return !left.Equals(right); + } } } \ No newline at end of file diff --git a/Runtime/Event.cs b/Runtime/Event.cs index 21e4719..7cc9e36 100644 --- a/Runtime/Event.cs +++ b/Runtime/Event.cs @@ -1,13 +1,43 @@ +using System; using System.Collections.Generic; namespace AptabaseSDK { - public struct Event + public struct Event : IEquatable { public string timestamp; public string sessionId; public string eventName; public EnvironmentInfo systemProps; public Dictionary props; + + public bool Equals(Event other) + { + return timestamp == other.timestamp + && sessionId == other.sessionId + && eventName == other.eventName + && systemProps == other.systemProps + && Equals(props, other.props); + } + + public override bool Equals(object obj) + { + return obj is Event other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(timestamp, sessionId, eventName, systemProps, props); + } + + public static bool operator ==(Event left, Event right) + { + return left.Equals(right); + } + + public static bool operator !=(Event left, Event right) + { + return !left.Equals(right); + } } } \ No newline at end of file diff --git a/Runtime/WebRequestHelper.cs b/Runtime/WebRequestHelper.cs index a1a8f89..ba9769d 100644 --- a/Runtime/WebRequestHelper.cs +++ b/Runtime/WebRequestHelper.cs @@ -1,4 +1,6 @@ +using System; using System.Text; +using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; @@ -7,38 +9,171 @@ namespace AptabaseSDK { public class WebRequestHelper { - public UnityWebRequest CreateWebRequest(string url, string appKey, EnvironmentInfo env, string contents) + private readonly string _appKey; + private readonly EnvironmentInfo _env; + private readonly string _url; + + public WebRequestHelper(string url, string appKey, EnvironmentInfo env) + { + if (string.IsNullOrEmpty(url)) + throw new ArgumentException("[AptabaseAnalytics] URL cannot be null or empty", nameof(url)); + + if (string.IsNullOrEmpty(appKey)) + throw new ArgumentException("[AptabaseAnalytics] AppKey cannot be null or empty", nameof(appKey)); + + _url = url; + _appKey = appKey; + _env = env; + } + + public async Task CreateAndSendWebRequestAsync(string contents, CancellationToken cancellationToken) + { + return await SendWebRequestAsync(CreateWebRequest(contents), cancellationToken); + } + + private UnityWebRequest CreateWebRequest(string contents) { - var webRequest = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST); + var webRequest = new UnityWebRequest(_url, UnityWebRequest.kHttpVerbPOST); webRequest.SetRequestHeader("Content-Type", "application/json"); - webRequest.SetRequestHeader("App-Key", appKey); -//webgl needs the default user-agent header. All other platforms we create manually + webRequest.SetRequestHeader("App-Key", _appKey); + // webgl needs the default user-agent header. All other platforms we create manually #if !UNITY_WEBGL - webRequest.SetRequestHeader("User-Agent", $"{env.osName}/${env.osVersion} ${env.locale}"); + webRequest.SetRequestHeader("User-Agent", $"{_env.osName}/${_env.osVersion} ${_env.locale}"); #endif - + webRequest.downloadHandler = new DownloadHandlerBuffer(); webRequest.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(contents)); return webRequest; } - - public async Task SendWebRequestAsync(UnityWebRequest request) + + private static async Task SendWebRequestAsync( + UnityWebRequest request, + CancellationToken cancellationToken) { var requestOp = request.SendWebRequest(); while (!requestOp.isDone) - await Task.Yield(); - - var success = requestOp.webRequest.result == UnityWebRequest.Result.Success; - if (success) { - request.Dispose(); + if (cancellationToken.IsCancellationRequested) + return false; + + await Task.Yield(); } - else + + var success = requestOp.webRequest.result is UnityWebRequest.Result.Success; + if (!success) + Debug.LogWarning( + $"[AptabaseAnalytics] Failed to perform web request due to {requestOp.webRequest.responseCode} " + + $"and response body {requestOp.webRequest.error}, " + + $"result: {requestOp.webRequest.result}."); + + switch (requestOp.webRequest.responseCode) { - Debug.LogWarning($"Failed to perform web request due to {requestOp.webRequest.responseCode} and response body {requestOp.webRequest.error}"); - request.Dispose(); + case 0: + { + Debug.LogWarning( + "[AptabaseAnalytics] Network error occurred. Please check your internet connection."); + break; + } + + case 200: // Success + case 201: // Created + case 202: // Accepted + case 204: // No Content + { + break; + } + + case 400: // Bad Request + { + Debug.LogWarning( + "[AptabaseAnalytics] Bad request sent to server. Check event data for correctness. May also happen if rate limits are exceeded for Aptabase Cloud."); + break; + } + + case 401: // Unauthorized + { + Debug.LogWarning("[AptabaseAnalytics] Unauthorized request. Please check your App Key."); + break; + } + + case 403: // Forbidden + { + Debug.LogWarning( + "[AptabaseAnalytics] Access forbidden. Your App Key may not have permission to send events to this endpoint."); + break; + } + + case 404: // Not Found + { + Debug.LogWarning( + "[AptabaseAnalytics] Endpoint not found. Please verify your server URL configuration."); + break; + } + + case 408: // Request Timeout + { + Debug.LogWarning("[AptabaseAnalytics] Request timed out. Server took too long to respond."); + break; + } + + case 413: // Payload Too Large + { + Debug.LogWarning( + "[AptabaseAnalytics] Request payload too large. Consider reducing batch size or event data size."); + break; + } + + case 429: // Too Many Requests + { + Debug.LogWarning( + "[AptabaseAnalytics] Rate limited by server. Consider increasing your flush interval or reducing event volume."); + break; + } + + case 500: // Internal Server Error + { + Debug.LogWarning( + "[AptabaseAnalytics] Internal server error occurred. This may be temporary, please try again later."); + break; + } + + case 502: // Bad Gateway + { + Debug.LogWarning( + "[AptabaseAnalytics] Bad gateway error. The server received an invalid response from upstream."); + break; + } + + case 503: // Service Unavailable + { + Debug.LogWarning( + "[AptabaseAnalytics] Service temporarily unavailable. Server may be under maintenance or overloaded."); + break; + } + + case 504: // Gateway Timeout + { + Debug.LogWarning( + "[AptabaseAnalytics] Gateway timeout. The server did not receive a timely response from upstream."); + break; + } + + case >= 500: // Other Server Errors + { + Debug.LogWarning( + $"[AptabaseAnalytics] Server error {requestOp.webRequest.responseCode} occurred. This may be temporary, please try again later."); + break; + } + + default: + { + Debug.LogWarning( + $"[AptabaseAnalytics] Unexpected response code {requestOp.webRequest.responseCode}. Error: {requestOp.webRequest.error}, result: {requestOp.webRequest.result}"); + break; + } } + request.Dispose(); return success; } } diff --git a/package.json b/package.json index a56dd22..04edb1f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "com.aptabase", "displayName": "Aptabase", - "version": "0.2.4", - "unity": "2018.4", + "version": "0.2.5", + "unity": "6000.0", "description": "Analytics for Apps. Privacy-First. Simple. Real-Time.", - "keywords": ["analytics"], + "keywords": [ + "analytics" + ], "category": "analytics", "dependencies": { }