diff --git a/CHANGELOG.md b/CHANGELOG.md index 1271dd85f..a13739050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixes - IL2CPP line numbers now work on Android x86/x86_64 builds. il2cpp fails to report the image UUID there, so the SDK falls back to looking the debug image up by name ([#2817](https://github.com/getsentry/sentry-unity/pull/2817)) +- When targeting WebGL or Nintendo Switch without native support, `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 300s) after a connection error instead of retrying on every envelope. ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) +- When targeting Nintendo Switch, the SDK now utilizes sentry-switch to poll the network status before sending. ([#2833](https://github.com/getsentry/sentry-unity/pull/2833)) ### Dependencies diff --git a/package-dev/Plugins/Switch/sentry_native_stubs.c b/package-dev/Plugins/Switch/sentry_native_stubs.c index 04836e9e0..57ffbf7d8 100644 --- a/package-dev/Plugins/Switch/sentry_native_stubs.c +++ b/package-dev/Plugins/Switch/sentry_native_stubs.c @@ -408,6 +408,12 @@ const char* sentry_switch_utils_get_default_user_id(void) return ""; } +int sentry_switch_utils_is_network_available(void) +{ + /* No native SDK to ask - assume usable and let the transport's backoff take over */ + return 1; +} + /* * ============================================================================= * Utility Functions diff --git a/src/Sentry.Unity.Native/SentryNativeSwitch.cs b/src/Sentry.Unity.Native/SentryNativeSwitch.cs index 6fab13909..b01a93ce5 100644 --- a/src/Sentry.Unity.Native/SentryNativeSwitch.cs +++ b/src/Sentry.Unity.Native/SentryNativeSwitch.cs @@ -28,8 +28,20 @@ public static class SentryNativeSwitch [DllImport("__Internal")] private static extern IntPtr sentry_switch_utils_get_default_user_id(); + [DllImport("__Internal")] + private static extern int sentry_switch_utils_is_network_available(); + private static IDiagnosticLogger? Logger; + /// + /// Whether the console currently has a usable network. + /// + /// + /// Application.internetReachability reports the console as reachable while it is offline, + /// and every send attempted in that state raises the system's "connect to the internet" dialog. + /// + internal static bool IsNetworkAvailable() => sentry_switch_utils_is_network_available() == 1; + /// /// Configures the native support for Nintendo Switch. /// @@ -72,6 +84,9 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } + Logger?.LogDebug("Using the native SDK to determine network availability."); + options.NetworkAvailabilityProbe = IsNetworkAvailable; + Logger?.LogDebug("Mounting temporary storage for sentry-switch."); if (sentry_switch_utils_mount() != 1) diff --git a/src/Sentry.Unity/Integrations/IApplication.cs b/src/Sentry.Unity/Integrations/IApplication.cs index 805c63543..cca143b4d 100644 --- a/src/Sentry.Unity/Integrations/IApplication.cs +++ b/src/Sentry.Unity/Integrations/IApplication.cs @@ -17,6 +17,7 @@ internal interface IApplication string UnityVersion { get; } string PersistentDataPath { get; } RuntimePlatform Platform { get; } + NetworkReachability InternetReachability { get; } } /// @@ -54,6 +55,8 @@ private ApplicationAdapter() public RuntimePlatform Platform => Application.platform; + public NetworkReachability InternetReachability => Application.internetReachability; + private void OnLogMessageReceived(string condition, string stackTrace, LogType type) => LogMessageReceived?.Invoke(condition, stackTrace, type); diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 1cb15ad5b..749339cba 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -493,6 +493,8 @@ internal string? DefaultUserId /// internal Action? NativeSupportCloseCallback { get; set; } = null; + internal Func? NetworkAvailabilityProbe { get; set; } = null; + internal List SdkIntegrationNames { get; set; } = new(); internal ISentryUnityInfo UnityInfo { get; private set; } diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index c19a18d8c..1e92d5d46 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -6,7 +6,11 @@ using System.Threading.Tasks; using Sentry.Extensibility; using Sentry.Http; +using Sentry.Internal; +using Sentry.Internal.Extensions; using Sentry.Protocol.Envelopes; +using Sentry.Unity.Integrations; +using UnityEngine; using UnityEngine.Networking; namespace Sentry.Unity; @@ -50,14 +54,31 @@ private IEnumerator SendAndTrack(Envelope envelope) internal class UnityWebRequestTransport : HttpTransportBase { private readonly SentryUnityOptions _options; + private readonly IApplication _application; - public UnityWebRequestTransport(SentryUnityOptions options) + private const double MaxBackoffSeconds = 300.0; + private double _backoffSeconds; + private double _retryAfterRealtime; + + public UnityWebRequestTransport(SentryUnityOptions options, IApplication? application = null) : base(options) - => _options = options; + { + _options = options; + _application = application ?? ApplicationAdapter.Instance; + } // adapted HttpTransport.SendEnvelopeAsync() internal IEnumerator SendEnvelopeAsync(Envelope envelope) { + // This transport opens a connection per envelope, causing a prompt to appear on platforms + // like Nintendo Switch while offline. + if (!IsNetworkAvailable() || Time.realtimeSinceStartupAsDouble < _retryAfterRealtime) + { + _options.LogDebug("Network unavailable or backing off. Dropping envelope instead of attempting to send."); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + yield break; + } + using var processedEnvelope = ProcessEnvelope(envelope); if (processedEnvelope.Items.Count > 0) { @@ -66,6 +87,20 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) var www = CreateWebRequest(httpRequest); yield return www.SendWebRequest(); + if (www.result == UnityWebRequest.Result.ConnectionError) + { + _backoffSeconds = Math.Min(MaxBackoffSeconds, _backoffSeconds > 0.0 ? _backoffSeconds * 2 : 1.0); + _retryAfterRealtime = Time.realtimeSinceStartupAsDouble + _backoffSeconds; + + _options.LogWarning("Failed to send request: {0}. Backing off for {1:F1}s.", www.error, _backoffSeconds); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, processedEnvelope); + RestoreClientReports(processedEnvelope); + yield break; + } + + _backoffSeconds = 0.0; + _retryAfterRealtime = 0.0; + var response = GetResponse(www); if (response is not null) { @@ -74,6 +109,30 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } } + // ProcessEnvelope drains the recorder into a client report item. Restore those counts instead of + // losing them along with the envelope we failed to send. + private void RestoreClientReports(Envelope envelope) + { + foreach (var item in envelope.Items) + { + if (item.TryGetType() == EnvelopeItem.TypeValueClientReport && + item.Payload is JsonSerializable { Source: ClientReport report }) + { + _options.ClientReportRecorder.Load(report); + } + } + } + + private bool IsNetworkAvailable() + { + if (_options.NetworkAvailabilityProbe is { } probe) + { + return probe(); + } + + return _application.InternetReachability != NetworkReachability.NotReachable; + } + private UnityWebRequest CreateWebRequest(HttpRequestMessage message) { using var contentStream = ReadStreamFromHttpContent(message.Content); diff --git a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs new file mode 100644 index 000000000..a25e9c8d8 --- /dev/null +++ b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs @@ -0,0 +1,79 @@ +using NUnit.Framework; +using Sentry.Protocol.Envelopes; +using Sentry.Unity.Tests.Stubs; +using UnityEngine; + +namespace Sentry.Unity.Tests; + +[TestFixture] +public class UnityWebRequestTransportTests +{ + private static SentryUnityOptions CreateOptions() => new() + { + Dsn = SentryTests.TestDsn + }; + + private static Envelope CreateEnvelope() => Envelope.FromEvent(new SentryEvent()); + + /// + /// A coroutine that yields has created a , + /// which is what raises the system connection dialog on platforms like the Nintendo Switch. + /// + [Test] + public void SendEnvelopeAsync_NetworkNotReachable_DoesNotOpenAConnection() + { + var application = new TestApplication { InternetReachability = NetworkReachability.NotReachable }; + var transport = new UnityWebRequestTransport(CreateOptions(), application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsFalse(enumerator.MoveNext(), "The transport attempted to send while offline."); + } + + [Test] + public void SendEnvelopeAsync_NetworkReachable_AttemptsToSend() + { + var application = new TestApplication + { + InternetReachability = NetworkReachability.ReachableViaLocalAreaNetwork + }; + var transport = new UnityWebRequestTransport(CreateOptions(), application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsTrue(enumerator.MoveNext(), "The transport did not attempt to send while online."); + } + + /// + /// The probe has the final say: on Nintendo Switch reachability claims the console is reachable + /// while it is offline, which is the state the dialog gets raised in. + /// + [Test] + public void SendEnvelopeAsync_ProbeReportsNoNetwork_OverridesReachability() + { + var application = new TestApplication + { + InternetReachability = NetworkReachability.ReachableViaLocalAreaNetwork + }; + var options = CreateOptions(); + options.NetworkAvailabilityProbe = () => false; + var transport = new UnityWebRequestTransport(options, application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsFalse(enumerator.MoveNext(), "The transport ignored the platform's network probe."); + } + + [Test] + public void SendEnvelopeAsync_ProbeReportsNetwork_OverridesReachability() + { + var application = new TestApplication { InternetReachability = NetworkReachability.NotReachable }; + var options = CreateOptions(); + options.NetworkAvailabilityProbe = () => true; + var transport = new UnityWebRequestTransport(options, application); + + var enumerator = transport.SendEnvelopeAsync(CreateEnvelope()); + + Assert.IsTrue(enumerator.MoveNext(), "The transport ignored the platform's network probe."); + } +} diff --git a/test/SharedClasses/TestApplication.cs b/test/SharedClasses/TestApplication.cs index 45ed584f9..2051fc3c0 100644 --- a/test/SharedClasses/TestApplication.cs +++ b/test/SharedClasses/TestApplication.cs @@ -41,6 +41,7 @@ public TestApplication( public string UnityVersion { get; set; } public string PersistentDataPath { get; set; } public RuntimePlatform Platform { get; set; } + public NetworkReachability InternetReachability { get; set; } = NetworkReachability.ReachableViaLocalAreaNetwork; private void OnLogMessageReceived(string condition, string stacktrace, LogType type) => LogMessageReceived?.Invoke(condition, stacktrace, type);