diff --git a/Runtime/BacktraceClient.cs b/Runtime/BacktraceClient.cs index 5a8f5233..4bb55202 100644 --- a/Runtime/BacktraceClient.cs +++ b/Runtime/BacktraceClient.cs @@ -8,6 +8,7 @@ using Backtrace.Unity.Runtime.Native; using Backtrace.Unity.Services; using Backtrace.Unity.Types; +using Backtrace.Unity.WebGL; using System; using System.Collections; using System.Collections.Generic; @@ -131,6 +132,17 @@ internal System.Random Random /// private HashSet _clientReportAttachments; +#if UNITY_WEBGL + private WebGLOfflineDatabase _webglOfflineDatabase; + private Coroutine _webglOfflineReplayCoroutine; + private bool _webglOfflineReplayInProgress; + + // WebGL builds may have multiple BacktraceClient instances if DestroyOnLoad is enabled. + // Ensure only one instance performs offline queue replay to avoid duplicate sends. + private static BacktraceClient _webglOfflineReplayOwner; +#endif + + /// /// Attribute object accessor /// @@ -518,6 +530,9 @@ public static BacktraceClient Initialize(string url, Dictionary public void OnDisable() { Enabled = false; +#if UNITY_WEBGL + StopWebGLOfflineReplay(); +#endif } public void Refresh() @@ -553,6 +568,11 @@ public void Refresh() #endif ); BacktraceApi.EnablePerformanceStatistics = Configuration.PerformanceStatistics; +#if UNITY_WEBGL + // JS page lifecycle hooks + BacktraceWebGLSync.TryInstallPageLifecycleHooks(); +#endif + if (!Configuration.DestroyOnLoad) { @@ -579,6 +599,9 @@ public void Refresh() } } } +#if UNITY_WEBGL + InitializeWebGLOfflineSupport(); +#endif if (Database != null) { // send minidump files generated by unity engine or unity game, not captured by Windows native integration @@ -598,6 +621,178 @@ public void Refresh() AttributeProvider.AddDynamicAttributeProvider(_nativeClient); } } +#if UNITY_WEBGL + private void InitializeWebGLOfflineSupport() + { + // WebGL offline queue is only active when Backtrace offline database is enabled in configuration. + if (Configuration == null || !Configuration.Enabled) + { + return; + } + + if (_webglOfflineDatabase == null) + { + _webglOfflineDatabase = new WebGLOfflineDatabase(Configuration); + } + + // Clean up corrupted/invalid entries and enforce size/count bounds on start. + _webglOfflineDatabase.Compact(); + + // auto-send stored reports if AutoSendMode is enabled. + if (!Configuration.AutoSendMode) + { + return; + } + + // Avoid multiple BacktraceClient instances replaying the same persisted queue. + if (_webglOfflineReplayOwner != null && _webglOfflineReplayOwner != this) + { + return; + } + _webglOfflineReplayOwner = this; + + if (_webglOfflineReplayCoroutine == null) + { + _webglOfflineReplayCoroutine = StartCoroutine(WebGLOfflineReplayLoop()); + } + } + + private IEnumerator WebGLOfflineReplayLoop() + { + // Wait one frame to allow other initialization to complete. + yield return null; + + while (Enabled && Configuration != null && Configuration.Enabled && Configuration.AutoSendMode) + { + if (_webglOfflineDatabase != null && + !_webglOfflineDatabase.IsEmpty && + BacktraceApi != null && + RequestHandler == null) + { + yield return SendCachedWebGLReports(); + } + + var retryInterval = Configuration.RetryInterval > 0 + ? Configuration.RetryInterval + : BacktraceConfiguration.DefaultRetryInterval; + + yield return new WaitForSecondsRealtime(retryInterval); + } + + _webglOfflineReplayCoroutine = null; + _webglOfflineReplayInProgress = false; + } + + /// + /// Send any reports cached in PlayerPrefs while the WebGL client was offline and the BacktraceDatabase was not available or disabled. + /// + private IEnumerator SendCachedWebGLReports() + { + if (_webglOfflineReplayInProgress) + { + yield break; + } + + _webglOfflineReplayInProgress = true; + + try + { + if (_webglOfflineDatabase == null || BacktraceApi == null || Configuration == null) + { + yield break; + } + + // RequestHandler overrides normal send path. We can't replay stored JSON through it. + if (RequestHandler != null) + { + yield break; + } + + var retryOrder = Configuration.RetryOrder; + var retryLimit = Mathf.Max(1, Configuration.RetryLimit); + + while (_webglOfflineDatabase.TryPeek(retryOrder, out var record)) + { + if (record == null) + { + yield break; + } + + // Respect client-side report rate limiting. + if (_reportLimitWatcher != null && !_reportLimitWatcher.WatchReport(DateTimeHelper.Timestamp())) + { + yield break; + } + + // Give up on records that exceeded retry limit. + if (record.attempts >= retryLimit) + { + _webglOfflineDatabase.Remove(record.uuid); + yield return null; + continue; + } + + var queryAttributes = new Dictionary(); + if (record.deduplication != 0) + { + queryAttributes["_mod_duplicate"] = record.deduplication.ToString(CultureInfo.InvariantCulture); + } + + BacktraceResult sendResult = null; + yield return BacktraceApi.Send( + record.json, + record.attachments ?? new string[0], + queryAttributes, + result => sendResult = result); + + if (sendResult == null) + { + _webglOfflineDatabase.IncrementAttempts(record.uuid); + yield break; + } + + if (sendResult.Status == BacktraceResultStatus.Ok || sendResult.Status == BacktraceResultStatus.Empty) + { + _webglOfflineDatabase.Remove(record.uuid); + yield return null; + continue; + } + + // Treat all non-success status as retryable attempts for the WebGL offline replay. + if (sendResult.Status == BacktraceResultStatus.ServerError || + sendResult.Status == BacktraceResultStatus.NetworkError || + sendResult.Status == BacktraceResultStatus.LimitReached) + { + _webglOfflineDatabase.IncrementAttempts(record.uuid); + yield break; + } + + yield break; + } + } + finally + { + _webglOfflineReplayInProgress = false; + } + } + + private void StopWebGLOfflineReplay() + { + if (_webglOfflineReplayCoroutine != null) + { + StopCoroutine(_webglOfflineReplayCoroutine); + _webglOfflineReplayCoroutine = null; + } + + _webglOfflineReplayInProgress = false; + + if (_webglOfflineReplayOwner == this) + { + _webglOfflineReplayOwner = null; + } + } +#endif + public bool EnableBreadcrumbsSupport() { @@ -673,6 +868,10 @@ private void StartupMetrics() private void OnApplicationQuit() { +#if UNITY_WEBGL + StopWebGLOfflineReplay(); + BacktraceWebGLSync.TrySyncFileSystem(true); +#endif if (_nativeClient != null) { _nativeClient.Disable(); @@ -721,6 +920,10 @@ private void LateUpdate() private void OnDestroy() { Enabled = false; +#if UNITY_WEBGL + StopWebGLOfflineReplay(); + BacktraceWebGLSync.TrySyncFileSystem(true); +#endif if (_breadcrumbs != null) { _breadcrumbs.FromMonoBehavior("Backtrace Client: OnDestroy", LogType.Warning, null); @@ -867,7 +1070,8 @@ private IEnumerator CollectDataAndSend(BacktraceReport report, Action + /// Lightweight PlayerPrefs offline queue for WebGL. + /// + /// This is a fallback persistence mechanism used when Backtrace offline database is enabled in + /// but the on-disk is not available + /// (for example: invalid path, directory creation disabled or filesystem sync limitations in WebGL). + /// + /// The queue is bounded by Backtrace database configuration values (MaxRecordCount / MaxDatabaseSize), + /// but also enforces additional hard caps suitable for WebGL to avoid excessive PlayerPrefs usage. + /// + internal sealed class WebGLOfflineDatabase + { + /// + /// Storage key prefix. A stable hash is appended. + /// + private const string StorageKeyPrefix = "backtrace-webgl-offline-queue-"; + + /// + /// WebGL hard cap: maximum number of records stored in PlayerPrefs. + /// + private const int HardMaxRecords = 32; + + /// + /// WebGL hard cap: maximum total payload size in bytes stored in PlayerPrefs. + /// + private const int HardMaxTotalBytes = 1000 * 1000; // ~1 MB + + /// + /// WebGL hard cap: maximum size in bytes for a single report JSON payload. + /// + private const int HardMaxRecordBytes = 256 * 1024; // 256 KB + + private readonly BacktraceConfiguration _configuration; + private readonly string _storageKey; + + private WebGLOfflineQueue _cache; + + [Serializable] + internal sealed class WebGLOfflineRecord + { + /// + /// Backtrace report UUID used for lookup. + /// + public string uuid; + + /// + /// Serialized BacktraceData JSON to replay later. + /// + public string json; + + /// + /// Optional attachment paths. + /// + public string[] attachments; + + /// + /// Deduplication count. + /// + public int deduplication; + + /// + /// Unix timestamp (seconds). + /// + public long timestamp; + + /// + /// Number of failed send attempts for this record. + /// + public int attempts; + } + + [Serializable] + private sealed class WebGLOfflineQueue + { + public List items = new List(); + } + + public WebGLOfflineDatabase(BacktraceConfiguration configuration) + { + _configuration = configuration; + + // Use a stable hash so different environments don't share a queue. + var url = configuration != null ? configuration.GetValidServerUrl() : string.Empty; + var hash = ComputeFnv1aHash(url); + _storageKey = string.Format("{0}{1}", StorageKeyPrefix, hash); + + _cache = Load(); + } + + public bool IsEmpty + { + get { return _cache == null || _cache.items == null || _cache.items.Count == 0; } + } + + public int Count + { + get { return _cache == null || _cache.items == null ? 0 : _cache.items.Count; } + } + + /// + /// Remove corrupted or empty entries. + /// + public void Compact() + { + EnsureCache(); + + for (int i = _cache.items.Count - 1; i >= 0; i--) + { + var item = _cache.items[i]; + if (item == null || string.IsNullOrEmpty(item.uuid) || string.IsNullOrEmpty(item.json)) + { + _cache.items.RemoveAt(i); + } + } + + TrimToFit(); + Save(); + } + + /// + /// Queue a report for retry on a future session. + /// + public void Enqueue(Guid uuid, string json, IEnumerable attachments, int deduplication) + { + if (string.IsNullOrEmpty(json)) + { + return; + } + + var jsonBytes = GetUtf8ByteCount(json); + if (jsonBytes <= 0) + { + return; + } + + // If the record is too large for safe PlayerPrefs storage, skip it. + if (jsonBytes > HardMaxRecordBytes || jsonBytes > HardMaxTotalBytes) + { + return; + } + + EnsureCache(); + + _cache.items.Add(new WebGLOfflineRecord + { + uuid = uuid.ToString(), + json = json, + attachments = attachments != null ? new List(attachments).ToArray() : new string[0], + deduplication = deduplication, + timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + attempts = 0 + }); + + TrimToFit(); + Save(); + } + + /// + /// Try to peek a record without removing it. + /// + public bool TryPeek(RetryOrder retryOrder, out WebGLOfflineRecord record) + { + record = null; + + if (_cache == null || _cache.items == null || _cache.items.Count == 0) + { + return false; + } + + record = retryOrder == RetryOrder.Stack + ? _cache.items[_cache.items.Count - 1] + : _cache.items[0]; + + return record != null; + } + + /// + /// Remove a record by UUID. + /// + public void Remove(string uuid) + { + if (string.IsNullOrEmpty(uuid) || _cache == null || _cache.items == null) + { + return; + } + + for (int i = 0; i < _cache.items.Count; i++) + { + if (_cache.items[i] != null && _cache.items[i].uuid == uuid) + { + _cache.items.RemoveAt(i); + Save(); + return; + } + } + } + + /// + /// Increment send attempt counter for a record. + /// + public void IncrementAttempts(string uuid) + { + if (string.IsNullOrEmpty(uuid) || _cache == null || _cache.items == null) + { + return; + } + + for (int i = 0; i < _cache.items.Count; i++) + { + var item = _cache.items[i]; + if (item != null && item.uuid == uuid) + { + item.attempts++; + Save(); + return; + } + } + } + + private void EnsureCache() + { + if (_cache == null) + { + _cache = new WebGLOfflineQueue(); + } + if (_cache.items == null) + { + _cache.items = new List(); + } + } + + private WebGLOfflineQueue Load() + { + var raw = PlayerPrefs.GetString(_storageKey, string.Empty); + if (string.IsNullOrEmpty(raw)) + { + return new WebGLOfflineQueue(); + } + + try + { + var queue = JsonUtility.FromJson(raw); + return queue ?? new WebGLOfflineQueue(); + } + catch + { + // reset corrupted storage. + return new WebGLOfflineQueue(); + } + } + + private void Save() + { + try + { + EnsureCache(); + var json = JsonUtility.ToJson(_cache); + PlayerPrefs.SetString(_storageKey, json); + PlayerPrefs.Save(); + } + catch + { + // We never throw into the game, worst case we just don't persist this batch. + } + } + + private void TrimToFit() + { + EnsureCache(); + + var maxRecords = GetEffectiveMaxRecords(); + var maxBytes = GetEffectiveMaxBytes(); + + // Remove oldest entries to satisfy max record count. + while (_cache.items.Count > maxRecords) + { + _cache.items.RemoveAt(0); + } + + // Remove oldest entries to satisfy size limit. + var totalBytes = GetApproximateQueueBytes(_cache.items); + while (totalBytes > maxBytes && _cache.items.Count > 0) + { + totalBytes -= GetApproximateRecordBytes(_cache.items[0]); + _cache.items.RemoveAt(0); + } + } + + private int GetEffectiveMaxRecords() + { + int configured = BacktraceConfiguration.DefaultMaxRecordCount; + if (_configuration != null) + { + configured = _configuration.MaxRecordCount; + } + + if (configured < 0) + { + configured = BacktraceConfiguration.DefaultMaxRecordCount; + } + + if (configured == 0) + { + configured = HardMaxRecords; + } + + if (configured > HardMaxRecords) + { + configured = HardMaxRecords; + } + + return Mathf.Max(1, configured); + } + + private long GetEffectiveMaxBytes() + { + long configuredBytes = 0; + if (_configuration != null) + { + // Convert MB to bytes to match BacktraceDatabaseSettings. + configuredBytes = _configuration.MaxDatabaseSize * 1000L * 1000L; + } + + if (configuredBytes <= 0) + { + configuredBytes = HardMaxTotalBytes; + } + + if (configuredBytes > HardMaxTotalBytes) + { + configuredBytes = HardMaxTotalBytes; + } + + return configuredBytes; + } + + private static long GetApproximateQueueBytes(List records) + { + if (records == null) + { + return 0; + } + + long total = 0; + for (int i = 0; i < records.Count; i++) + { + total += GetApproximateRecordBytes(records[i]); + } + return total; + } + + private static long GetApproximateRecordBytes(WebGLOfflineRecord record) + { + if (record == null) + { + return 0; + } + + long bytes = 0; + bytes += GetUtf8ByteCount(record.uuid); + bytes += GetUtf8ByteCount(record.json); + + if (record.attachments != null) + { + for (int i = 0; i < record.attachments.Length; i++) + { + bytes += GetUtf8ByteCount(record.attachments[i]); + } + } + + // Rough overhead for JSON structure. + bytes += 64; + return bytes; + } + + private static int GetUtf8ByteCount(string value) + { + if (string.IsNullOrEmpty(value)) + { + return 0; + } + + try + { + return Encoding.UTF8.GetByteCount(value); + } + catch + { + // approximate fallback. + return value.Length; + } + } + + private static string ComputeFnv1aHash(string input) + { + if (string.IsNullOrEmpty(input)) + { + return "00000000"; + } + + unchecked + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + uint hash = offsetBasis; + + for (int i = 0; i < input.Length; i++) + { + hash ^= input[i]; + hash *= prime; + } + + return hash.ToString("x8"); + } + } + } +} +#endif diff --git a/Runtime/Services/WebGLOfflineDatabase.cs.meta b/Runtime/Services/WebGLOfflineDatabase.cs.meta new file mode 100644 index 00000000..2ea73e99 --- /dev/null +++ b/Runtime/Services/WebGLOfflineDatabase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e819d592e8a754787bbf279d05ae9ebc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebGL.meta b/Runtime/WebGL.meta new file mode 100644 index 00000000..80991c36 --- /dev/null +++ b/Runtime/WebGL.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d224731d75a374a0c88e0a50fbdc4f31 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebGL/BacktraceSync.jslib b/Runtime/WebGL/BacktraceSync.jslib new file mode 100644 index 00000000..4b9cb90e --- /dev/null +++ b/Runtime/WebGL/BacktraceSync.jslib @@ -0,0 +1,66 @@ +mergeInto(LibraryManager.library, { + BT_SyncFS: function () { + try { + if (typeof FS === 'undefined' || typeof FS.syncfs !== 'function') { + return; + } + + // Flush to IndexedDB. + FS.syncfs(false, function (err) { + // avoid logging on success to keep production consoles clean. + if (err) { + console.warn('[Backtrace] FS.syncfs error', err); + } + }); + } catch (e) { + // avoid throwing into the runtime. + } + }, + + BT_InstallPageLifecycleHooks: function () { + try { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return 0; + } + + if (window.__backtrace_syncfs_hooks_installed) { + return 1; + } + + window.__backtrace_syncfs_hooks_installed = true; + + var flush = function () { + try { + if (typeof FS === 'undefined' || typeof FS.syncfs !== 'function') { + return; + } + + FS.syncfs(false, function (err) { + if (err) { + console.warn('[Backtrace] FS.syncfs error', err); + } + }); + } catch (e) { + // Ignore. + } + }; + + // Page Lifecycle events hooks for flushing. + window.addEventListener('pagehide', flush); + window.addEventListener('beforeunload', flush); + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') { + flush(); + } + }); + + // Chrome-specific: fires when the page is being frozen. + window.addEventListener('freeze', flush); + + return 1; + } catch (e) { + return 0; + } + } +}); diff --git a/Runtime/WebGL/BacktraceSync.jslib.meta b/Runtime/WebGL/BacktraceSync.jslib.meta new file mode 100644 index 00000000..727d2182 --- /dev/null +++ b/Runtime/WebGL/BacktraceSync.jslib.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 1f377560c82e34ff0be83195364735c1 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + WebGL: WebGL + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WebGL/BacktraceWebGLSync.cs b/Runtime/WebGL/BacktraceWebGLSync.cs new file mode 100644 index 00000000..87a22352 --- /dev/null +++ b/Runtime/WebGL/BacktraceWebGLSync.cs @@ -0,0 +1,83 @@ +using System.Runtime.InteropServices; +using UnityEngine; + +namespace Backtrace.Unity.WebGL +{ + /// + /// WebGL helpers for flushing Emscripten IDBFS writes to IndexedDB. + /// + /// When the Backtrace database is enabled on WebGL, it stores records inside Unity's persistent data directory, which is backed by Emscripten's virtual filesystem. + /// + /// Unity's virtual filesystem does not always flush buffered writes to IndexedDB before the tab is closed or backgrounded. + /// To reduce report loss, the SDK installs browser lifecycle hooks (via a WebGL .jslib) and exposes explicit flush calls. + /// + internal static class BacktraceWebGLSync + { + // Debounce to avoid spamming FS.syncfs on frequent events / frequent report writes. + private const float MinSyncIntervalSeconds = 2f; + + private static bool _hooksAttempted; + private static bool _hooksInstalled; + private static float _lastSyncTime; + +#if UNITY_WEBGL && !UNITY_EDITOR + [DllImport("__Internal")] + private static extern int BT_InstallPageLifecycleHooks(); + + [DllImport("__Internal")] + private static extern void BT_SyncFS(); +#endif + + /// + /// Install JS page lifecycle hooks that flush FS to IndexedDB. + /// Safe to call multiple times. + /// + public static void TryInstallPageLifecycleHooks() + { +#if UNITY_WEBGL && !UNITY_EDITOR + if (_hooksInstalled || _hooksAttempted) + { + return; + } + + _hooksAttempted = true; + try + { + _hooksInstalled = BT_InstallPageLifecycleHooks() != 0; + } + catch + { + _hooksInstalled = false; + } +#endif + } + + /// + /// Flush FS to IndexedDB. + /// + /// If true, bypasses debounce interval. + public static void TrySyncFileSystem(bool force = false) + { +#if UNITY_WEBGL && !UNITY_EDITOR + if (!force) + { + var now = Time.realtimeSinceStartup; + if (now - _lastSyncTime < MinSyncIntervalSeconds) + { + return; + } + _lastSyncTime = now; + } + + try + { + BT_SyncFS(); + } + catch + { + // Intentionally ignored. Missing plugin or unsupported runtime. + } +#endif + } + } +} diff --git a/Runtime/WebGL/BacktraceWebGLSync.cs.meta b/Runtime/WebGL/BacktraceWebGLSync.cs.meta new file mode 100644 index 00000000..c06aafeb --- /dev/null +++ b/Runtime/WebGL/BacktraceWebGLSync.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74b243c9872654e068c8c1ae049e3e1a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: