diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfcc72..7f69023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.2.6 +- Added support for setting a "ResponseListener" via Aptabase.SetResponseListener. This allows you to receive callbacks with the HttpStatusCode for each event sent +- Added support for enabling or disabling the SDK via Aptabase.SetEnabled. This starts/stops polling as well. + +## 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 bd05f00..73eb3de 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,26 @@ Aptabase.TrackEvent("app_started", new Dictionary }); ``` -If you want to manually flush the event queue you can use +If you want to manually flush the event queue you can use: +```csharp +await Aptabase.Flush(); +``` +or ```csharp Aptabase.Flush(); ``` +If you want to react to HttpStatusCodes received from the server, you can use: +```csharp +Aptabase.SetResponseListener((statusCode) => UnityEngine.Debug.Log($"Aptabase response status code: {statusCode}")); +``` + +If you want to enable or disable the SDK (note: also starts/stops polling), you can use: +```csharp +Aptabase.SetEnabled(enabled); +``` +It defaults to **enabled**. + A few important notes: 1. The SDK will automatically enhance the event with some useful information, like the OS, the app version, and other things. diff --git a/Runtime/Aptabase.cs b/Runtime/Aptabase.cs index fc9e39d..f02ce5b 100644 --- a/Runtime/Aptabase.cs +++ b/Runtime/Aptabase.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Net; using System.Threading; using System.Threading.Tasks; using UnityEngine; +using UnityEngine.Pool; using Random = UnityEngine.Random; namespace AptabaseSDK @@ -13,99 +15,115 @@ 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 bool _isEnabled = true; 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) + + public static void SetResponseListener(Action onResponse) + { + if (_dispatcher == null) + { + Debug.LogError("[AptabaseAnalytics] Aptabase is not initialized. Please check your settings."); + return; + } + + _dispatcher.SetResponseListener(onResponse); + } + + public static void SetEnabled(bool enabled) + { + _isEnabled = enabled; + + if (!enabled) + StopPolling(); + else + _ = StartPolling(GetFlushInterval()); + } + + 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) { break; } - } } 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(); - } + if (_isEnabled) + _ = hasFocus ? StartPolling(GetFlushInterval()) : Flush().ContinueWith(_ => StopPolling()); } private static string EvalSessionId() @@ -118,14 +136,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 +154,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,14 +177,14 @@ public static void TrackEvent(string eventName, Dictionary props systemProps = _env, props = props }; - + _dispatcher.Enqueue(eventData); } private static int GetFlushInterval() { if (_settings.EnableOverride && _settings.FlushInterval > 0) - return Mathf.Max(0, _settings.FlushInterval); + return _settings.FlushInterval; return _env.isDebug ? 2000 : 60000; } diff --git a/Runtime/Dispatcher/Dispatcher.cs b/Runtime/Dispatcher/Dispatcher.cs index c38ed68..f2846f8 100644 --- a/Runtime/Dispatcher/Dispatcher.cs +++ b/Runtime/Dispatcher/Dispatcher.cs @@ -1,86 +1,98 @@ +using System; using System.Collections.Generic; +using System.Linq; +using System.Net; +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) + public void SetResponseListener(Action onResponse) { - _events.Enqueue(data); + _webRequestHelper.SetResponseListener(onResponse); } - - private void Enqueue(List data) + + public void Enqueue(Event data) { - foreach (var eventData in data) - _events.Enqueue(eventData); + _events.Enqueue(data); } - 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); + + ListPool.Release(eventsToSend); } + } while (_events.Count > 0 && !cancellationToken.IsCancellationRequested); - } while (_events.Count > 0); - - if (failedEvents.Count > 0) - Enqueue(failedEvents); + 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..7261c4f 100644 --- a/Runtime/Dispatcher/IDispatcher.cs +++ b/Runtime/Dispatcher/IDispatcher.cs @@ -1,9 +1,16 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + namespace AptabaseSDK { public interface IDispatcher { - public void Enqueue(Event data); + void SetResponseListener(Action onResponse); + + 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..483bc09 100644 --- a/Runtime/Dispatcher/WebGLDispatcher.cs +++ b/Runtime/Dispatcher/WebGLDispatcher.cs @@ -1,80 +1,79 @@ +using System; using System.Collections.Generic; +using System.Net; +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) + + public void SetResponseListener(Action onResponse) { - _events.Enqueue(data); - Flush(); + _webRequestHelper.SetResponseListener(onResponse); } - - private void Enqueue(List data) + + public void Enqueue(Event data) { - foreach (var eventData in data) - _events.Enqueue(eventData); + _events.Enqueue(data); + _ = 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..fc48b5a 100644 --- a/Runtime/WebRequestHelper.cs +++ b/Runtime/WebRequestHelper.cs @@ -1,4 +1,7 @@ +using System; +using System.Net; using System.Text; +using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; @@ -7,39 +10,85 @@ namespace AptabaseSDK { public class WebRequestHelper { - public UnityWebRequest CreateWebRequest(string url, string appKey, EnvironmentInfo env, string contents) + private readonly string _appKey; + private readonly string _url; + private readonly string _userAgent; + private Action _onResponse; + + 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; + _userAgent = $"{env.osName}/{env.osVersion} {env.locale}"; + } + + public async Task CreateAndSendWebRequestAsync(string contents, CancellationToken cancellationToken) { - var webRequest = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST); + return await SendWebRequestAsync(CreateWebRequest(contents), cancellationToken); + } + + private UnityWebRequest CreateWebRequest(string contents) + { + 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", _userAgent); #endif - + webRequest.downloadHandler = new DownloadHandlerBuffer(); webRequest.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(contents)); return webRequest; } - - public async Task SendWebRequestAsync(UnityWebRequest request) + + private async Task SendWebRequestAsync( + UnityWebRequest request, + CancellationToken cancellationToken) { var requestOp = request.SendWebRequest(); while (!requestOp.isDone) + { + if (cancellationToken.IsCancellationRequested) + return false; + await Task.Yield(); + } - var success = requestOp.webRequest.result == UnityWebRequest.Result.Success; - if (success) + 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}."); + + try { - request.Dispose(); + // Invoke the user's (optional) callback with the response code + _onResponse?.Invoke((HttpStatusCode)requestOp.webRequest.responseCode); } - else + catch (Exception ex) + { + // Ignore any exceptions thrown by the callback to avoid crashing the application + Debug.LogException(ex); + } + finally { - Debug.LogWarning($"Failed to perform web request due to {requestOp.webRequest.responseCode} and response body {requestOp.webRequest.error}"); request.Dispose(); } return success; } + + public void SetResponseListener(Action onResponse) + { + _onResponse = onResponse; + } } } \ No newline at end of file diff --git a/llms.txt.meta b/llms.txt.meta new file mode 100644 index 0000000..8808777 --- /dev/null +++ b/llms.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 80fc4f121cd45f94dba9f1679b18b8a5 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index a56dd22..53c7760 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.6", + "unity": "6000.0", "description": "Analytics for Apps. Privacy-First. Simple. Real-Time.", - "keywords": ["analytics"], + "keywords": [ + "analytics" + ], "category": "analytics", "dependencies": { }