diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bde06bde..770fb4b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Features + +- Added Nintendo Switch 2 support. The SDK now recognises the platform, links the Switch 2 build of the native library from `Assets/Plugins/Sentry/Switch2/`, and uploads its debug symbols. Switch 2 shares the existing `SwitchNativeSupportEnabled` option ([#2831](https://github.com/getsentry/sentry-unity/pull/2831)) + +### Fixes + +- The `UnityWebRequestTransport` no longer opens a connection while the platform reports no network, and backs off exponentially (1s up to 60s) after a connection error instead of retrying on every envelope. On Nintendo Switch each send attempt made while offline raises the system's "connect to the internet" dialog, so a game with logs or metrics enabled produced a prompt every few seconds ([#2831](https://github.com/getsentry/sentry-unity/pull/2831)) +- The `UnityLogger` no longer throws while logging an exception whose stack trace cannot be stringified. On Nintendo Switch this escaped from inside the handler that had already dealt with the original error, aborting SDK initialization and leaving the Unity integrations unregistered ([#2831](https://github.com/getsentry/sentry-unity/pull/2831)) +- On Nintendo Switch the SDK now asks the native SDK whether the network is usable before sending, instead of relying on `Application.internetReachability`, which reports the console as reachable while it is offline. Every send attempted in that state raised the system's "connect to the internet" dialog. Requires a sentry-switch build exposing `sentry_switch_utils_is_network_available()` ([#2831](https://github.com/getsentry/sentry-unity/pull/2831)) + ### Dependencies - Bump .NET SDK from v6.8.0 to v6.9.0 ([#2815](https://github.com/getsentry/sentry-unity/pull/2815)) diff --git a/package-dev/Plugins/Switch/sentry_native_stubs.c b/package-dev/Plugins/Switch/sentry_native_stubs.c index 04836e9e0..fcc0e5962 100644 --- a/package-dev/Plugins/Switch/sentry_native_stubs.c +++ b/package-dev/Plugins/Switch/sentry_native_stubs.c @@ -408,6 +408,13 @@ const char* sentry_switch_utils_get_default_user_id(void) return ""; } +int sentry_switch_utils_is_network_available(void) +{ + /* Return 1 - with no native SDK to ask, assume the network is usable and let the + transport fall back on its connection-error backoff */ + return 1; +} + /* * ============================================================================= * Utility Functions diff --git a/package-dev/Plugins/Switch/sentry_native_stubs.c.meta b/package-dev/Plugins/Switch/sentry_native_stubs.c.meta index 33f21e6e4..c72bd14c2 100644 --- a/package-dev/Plugins/Switch/sentry_native_stubs.c.meta +++ b/package-dev/Plugins/Switch/sentry_native_stubs.c.meta @@ -25,6 +25,7 @@ PluginImporter: Exclude Linux64: 1 Exclude OSXUniversal: 1 Exclude Switch: 0 + Exclude Switch2: 0 Exclude WebGL: 1 Exclude Win: 1 Exclude Win64: 1 @@ -46,6 +47,9 @@ PluginImporter: Switch: enabled: 1 settings: {} + Switch2: + enabled: 1 + settings: {} Win: enabled: 0 settings: diff --git a/package-dev/Runtime/Sentry.Unity.Native.Switch.dll.meta b/package-dev/Runtime/Sentry.Unity.Native.Switch.dll.meta index 25c262827..927a6240c 100644 --- a/package-dev/Runtime/Sentry.Unity.Native.Switch.dll.meta +++ b/package-dev/Runtime/Sentry.Unity.Native.Switch.dll.meta @@ -26,6 +26,7 @@ PluginImporter: Exclude OSXUniversal: 1 Exclude PS5: 1 Exclude Switch: 0 + Exclude Switch2: 0 Exclude WebGL: 1 Exclude Win: 1 Exclude Win64: 1 @@ -50,6 +51,9 @@ PluginImporter: Switch: enabled: 1 settings: {} + Switch2: + enabled: 1 + settings: {} Win: enabled: 0 settings: diff --git a/package-dev/Runtime/SentryInitialization.cs b/package-dev/Runtime/SentryInitialization.cs index ddb20b6a0..7f2f84ad3 100644 --- a/package-dev/Runtime/SentryInitialization.cs +++ b/package-dev/Runtime/SentryInitialization.cs @@ -20,6 +20,10 @@ #define SENTRY_NATIVE_SWITCH #endif +#if UNITY_SWITCH2 +#define SENTRY_NATIVE_SWITCH2 +#endif + #if UNITY_WEBGL #define SENTRY_WEBGL #endif @@ -45,7 +49,7 @@ using Sentry.Unity.iOS; #elif SENTRY_NATIVE_ANDROID using Sentry.Unity.Android; -#elif SENTRY_NATIVE || SENTRY_NATIVE_SWITCH +#elif SENTRY_NATIVE || SENTRY_NATIVE_SWITCH || SENTRY_NATIVE_SWITCH2 using Sentry.Unity.Native; #elif SENTRY_WEBGL using Sentry.Unity.WebGL; @@ -108,7 +112,7 @@ private static void SetUpPlatformServices() SentryPlatformServices.PlatformConfiguration = SentryNativeCocoa.Configure; #elif SENTRY_NATIVE_ANDROID SentryPlatformServices.PlatformConfiguration = SentryNativeAndroid.Configure; -#elif SENTRY_NATIVE_SWITCH +#elif SENTRY_NATIVE_SWITCH || SENTRY_NATIVE_SWITCH2 SentryPlatformServices.PlatformConfiguration = SentryNativeSwitch.Configure; #elif SENTRY_NATIVE SentryPlatformServices.PlatformConfiguration = SentryNative.Configure; diff --git a/package-dev/Runtime/io.sentry.unity.dev.runtime.asmdef b/package-dev/Runtime/io.sentry.unity.dev.runtime.asmdef index 357a78477..3f491e257 100644 --- a/package-dev/Runtime/io.sentry.unity.dev.runtime.asmdef +++ b/package-dev/Runtime/io.sentry.unity.dev.runtime.asmdef @@ -9,6 +9,7 @@ "LinuxStandalone64", "macOSStandalone", "Switch", + "Switch2", "PS5", "WSA", "WebGL", diff --git a/package/Runtime/io.sentry.unity.runtime.asmdef b/package/Runtime/io.sentry.unity.runtime.asmdef index e9e1c7df5..38dd0ef4a 100644 --- a/package/Runtime/io.sentry.unity.runtime.asmdef +++ b/package/Runtime/io.sentry.unity.runtime.asmdef @@ -12,6 +12,7 @@ "macOSStandalone", "PS5", "Switch", + "Switch2", "WSA", "WebGL", "WindowsStandalone32", diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs index 241708c97..12d08b08a 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/AdvancedTab.cs @@ -176,7 +176,7 @@ internal static void Display(ScriptableSentryUnityOptions options, SentryCliOpti options.PlayStationNativeSupportEnabled); options.SwitchNativeSupportEnabled = EditorGUILayout.Toggle( - new GUIContent("Nintendo Switch", "Whether to enable native scope sync support on Nintendo Switch."), + new GUIContent("Nintendo Switch", "Whether to enable native scope sync support on Nintendo Switch and Switch 2."), options.SwitchNativeSupportEnabled); } diff --git a/src/Sentry.Unity.Editor/Native/SwitchNativePluginBuildPreProcess.cs b/src/Sentry.Unity.Editor/Native/SwitchNativePluginBuildPreProcess.cs index 104ec7e92..673f6c97f 100644 --- a/src/Sentry.Unity.Editor/Native/SwitchNativePluginBuildPreProcess.cs +++ b/src/Sentry.Unity.Editor/Native/SwitchNativePluginBuildPreProcess.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using Sentry.Extensibility; @@ -21,17 +22,37 @@ namespace Sentry.Unity.Editor.Native; /// internal class SwitchNativePluginBuildPreProcess : IPreprocessBuildWithReport { - private static readonly string[] RequiredFiles = + /// + /// BuildTarget.Switch2 only exists in Unity 6000.3 and newer, and this assembly is + /// compiled against a single Unity version, so the target is matched by name. The build report + /// hands us the value itself, so the enum member never has to be referenced. + /// + internal const string Switch2BuildTargetName = "Switch2"; + + private static bool IsSwitchFamily(BuildTarget target) => + target == BuildTarget.Switch || IsSwitch2(target); + + private static bool IsSwitch2(BuildTarget target) => + string.Equals(target.ToString(), Switch2BuildTargetName, StringComparison.Ordinal); + + /// + /// Both platforms share one stub, so the required libraries are what differ between them. + /// + private static string[] RequiredFilesFor(BuildTarget target) { - "Assets/Plugins/Sentry/Switch/libsentry.a", - "Assets/Plugins/Sentry/Switch/libzstd.a", - }; + var directory = IsSwitch2(target) ? Switch2BuildTargetName : nameof(BuildTarget.Switch); + return new[] + { + $"Assets/Plugins/Sentry/{directory}/libsentry.a", + $"Assets/Plugins/Sentry/{directory}/libzstd.a", + }; + } public int callbackOrder => -100; public void OnPreprocessBuild(BuildReport report) { - if (report.summary.platform != BuildTarget.Switch) + if (!IsSwitchFamily(report.summary.platform)) { return; } @@ -39,14 +60,19 @@ public void OnPreprocessBuild(BuildReport report) var options = SentryScriptableObject.LoadOptions(isBuilding: true); var logger = options?.DiagnosticLogger ?? new UnityLogger(new SentryUnityOptions()); - ConfigureStub(logger, options?.SwitchNativeSupportEnabled ?? false); + // Switch 2 reuses the Switch implementation and therefore its option. + ConfigureStub(logger, options?.SwitchNativeSupportEnabled ?? false, report.summary.platform); } - internal static void ConfigureStub(IDiagnosticLogger logger, bool nativeSupportEnabled) + internal static void ConfigureStub(IDiagnosticLogger logger, bool nativeSupportEnabled, BuildTarget target) { - logger.LogDebug("Switch native support: checking for required files:\n{0}", - string.Join("\n", RequiredFiles.Select(f => $" - {f}"))); + var requiredFiles = RequiredFilesFor(target); + + logger.LogDebug("{0} native support: checking for required files:\n{1}", + target, string.Join("\n", requiredFiles.Select(f => $" - {f}"))); + // One stub serves both platforms; the importer tracks compatibility per build target, so + // enabling it for one does not affect the other. var stubPath = Path.Combine("Packages", SentryPackageInfo.GetName(), "Plugins", "Switch", "sentry_native_stubs.c"); var importer = AssetImporter.GetAtPath(stubPath) as PluginImporter; @@ -56,14 +82,16 @@ internal static void ConfigureStub(IDiagnosticLogger logger, bool nativeSupportE return; } - var existingFiles = RequiredFiles.Where(File.Exists).ToList(); - var missingFiles = RequiredFiles.Except(existingFiles).ToList(); + var existingFiles = requiredFiles.Where(File.Exists).ToList(); + var missingFiles = requiredFiles.Except(existingFiles).ToList(); var someFilesPresent = existingFiles.Count > 0 && missingFiles.Count > 0; if (someFilesPresent) { + // LogError has no two-argument overload that does not also take an exception, so the + // target goes into the format string rather than being passed alongside the file list. logger.LogError( - "Switch native support is partially configured. Missing files:\n{0}\n" + + target + " native support is partially configured. Missing files:\n{0}\n" + "Please add all required files to enable native support, or remove all files to fall back on no-op stubs.\n" + "Build sentry-switch and copy the libraries to the expected locations. " + "See: https://github.com/getsentry/sentry-switch", @@ -75,26 +103,26 @@ internal static void ConfigureStub(IDiagnosticLogger logger, bool nativeSupportE var allFilesPresent = missingFiles.Count == 0; if (allFilesPresent) { - logger.LogInfo("Switch native libraries found:\n{0}", - string.Join("\n", existingFiles.Select(f => $" - {f}"))); - importer.SetCompatibleWithPlatform(BuildTarget.Switch, false); + logger.LogInfo("{0} native libraries found:\n{1}", + target, string.Join("\n", existingFiles.Select(f => $" - {f}"))); + importer.SetCompatibleWithPlatform(target, false); } else { if (nativeSupportEnabled) { logger.LogWarning( - "Switch native support is enabled but required files are missing:\n{0}\n" + + "{0} native support is enabled but required files are missing:\n{1}\n" + "Build sentry-switch and copy the libraries to the expected locations. " + "See: https://github.com/getsentry/sentry-switch", - string.Join("\n", missingFiles.Select(f => $" - {f}")) + target, string.Join("\n", missingFiles.Select(f => $" - {f}")) ); } else { - logger.LogDebug("Switch native support is disabled. Enabling stubs (native calls will be no-op)."); + logger.LogDebug("{0} native support is disabled. Enabling stubs (native calls will be no-op).", target); } - importer.SetCompatibleWithPlatform(BuildTarget.Switch, true); + importer.SetCompatibleWithPlatform(target, true); } importer.SaveAndReimport(); diff --git a/src/Sentry.Unity.Native/SentryNativeSwitch.cs b/src/Sentry.Unity.Native/SentryNativeSwitch.cs index 6fab13909..4ca0b243d 100644 --- a/src/Sentry.Unity.Native/SentryNativeSwitch.cs +++ b/src/Sentry.Unity.Native/SentryNativeSwitch.cs @@ -28,8 +28,21 @@ 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; + /// + /// Queries the network interface manager without submitting a network request. + /// + /// + /// Unity's Application.internetReachability reports the console as reachable even while + /// it is offline, and every send attempted in that state raises the system's "connect to the + /// internet" dialog. Asking the native SDK first is what keeps the dialog off the screen. + /// + internal static bool IsNetworkAvailable() => sentry_switch_utils_is_network_available() == 1; + /// /// Configures the native support for Nintendo Switch. /// @@ -72,6 +85,11 @@ internal static void Configure(SentryUnityOptions options, RuntimePlatform platf return; } + // Wired up before the storage and native SDK setup below: the probe only needs the native + // library to be linked, so the transport keeps the benefit even if either of those fails. + 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/Properties/AssemblyInfo.cs b/src/Sentry.Unity/Properties/AssemblyInfo.cs index 9e8226ca7..2254b5e0e 100644 --- a/src/Sentry.Unity/Properties/AssemblyInfo.cs +++ b/src/Sentry.Unity/Properties/AssemblyInfo.cs @@ -3,6 +3,7 @@ [assembly: InternalsVisibleTo("Sentry.Unity.Native")] [assembly: InternalsVisibleTo("Sentry.Unity.Native.PlayStation")] [assembly: InternalsVisibleTo("Sentry.Unity.Native.Switch")] +[assembly: InternalsVisibleTo("Sentry.Unity.Native.Switch2")] [assembly: InternalsVisibleTo("Sentry.Unity.Native.Xbox")] [assembly: InternalsVisibleTo("Sentry.Unity.Tests")] [assembly: InternalsVisibleTo("Sentry.Unity.Editor")] diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 03af07038..e0574e2b6 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -494,6 +494,18 @@ internal string? DefaultUserId /// internal Action? NativeSupportCloseCallback { get; set; } = null; + /// + /// Reports whether the network is currently usable, when the platform can answer that more + /// reliably than . + /// + /// + /// Set by the platform configuration where a native probe exists. On Nintendo Switch this + /// matters twice over: reachability reports the console as reachable while it is offline, and + /// merely attempting a connection there raises the system's "connect to the internet" dialog. + /// Must not block - it is called on the main thread before each send. + /// + internal Func? NetworkAvailabilityProbe { get; set; } = null; + internal List SdkIntegrationNames { get; set; } = new(); internal ISentryUnityInfo UnityInfo { get; private set; } diff --git a/src/Sentry.Unity/SentryUnityOptionsExtensions.cs b/src/Sentry.Unity/SentryUnityOptionsExtensions.cs index cf2dc13dc..de5553103 100644 --- a/src/Sentry.Unity/SentryUnityOptionsExtensions.cs +++ b/src/Sentry.Unity/SentryUnityOptionsExtensions.cs @@ -49,9 +49,27 @@ internal static bool IsValid(this SentryUnityOptions options) return true; } + /// + /// RuntimePlatform.Switch2 was only added in Unity 6000.3. This assembly is compiled + /// against a single Unity version while the SDK still supports 2021.3, so Switch 2 is matched + /// by name instead of by enum member - referencing the member directly would stop the SDK from + /// building against the editors that predate it. + /// + internal const string Switch2PlatformName = "Switch2"; + + internal static bool IsSwitch2(this RuntimePlatform platform) => + string.Equals(platform.ToString(), Switch2PlatformName, StringComparison.Ordinal); + internal static bool IsNativeSupportEnabled(this SentryUnityOptions options, RuntimePlatform? platform = null) { platform ??= ApplicationAdapter.Instance.Platform; + + // Switch 2 reuses the Switch native support, and therefore its option. + if (platform.Value.IsSwitch2()) + { + return options.SwitchNativeSupportEnabled; + } + return platform switch { RuntimePlatform.Android => options.AndroidNativeSupportEnabled, diff --git a/src/Sentry.Unity/UnityLogger.cs b/src/Sentry.Unity/UnityLogger.cs index 4f7ef6b0a..d0d667856 100644 --- a/src/Sentry.Unity/UnityLogger.cs +++ b/src/Sentry.Unity/UnityLogger.cs @@ -30,7 +30,59 @@ public void Log(SentryLevel logLevel, string? message, Exception? exception = nu return; } - _logger.Log(GetUnityLogType(logLevel), LogTag, $"({logLevel.ToString()}) {Format(message, args)} {exception}"); + // A diagnostic logger must never take its caller down with it. Callers routinely log from + // inside a `catch` that already handled the failure - if logging throws there, a handled + // error turns into a fatal one. That is not hypothetical: on Nintendo Switch some + // exceptions throw again while their stack trace is being stringified, which aborted SDK + // initialization from inside the handler that had already dealt with the original problem. + string logMessage; + try + { + logMessage = $"({logLevel.ToString()}) {Format(message, args)} {Describe(exception)}"; + } + catch (Exception e) + { + logMessage = $"({logLevel.ToString()}) Failed to format log message: {e.GetType().Name}"; + } + + try + { + _logger.Log(GetUnityLogType(logLevel), LogTag, logMessage); + } + catch + { + // Reporting a logging failure would have to go through the very thing that just failed. + } + } + + /// + /// Renders an exception without trusting , which walks the + /// stack trace and can throw on platforms with limited stack trace support. + /// + private static string Describe(Exception? exception) + { + if (exception is null) + { + return Empty; + } + + try + { + return exception.ToString(); + } + catch (Exception e) + { + // Type and message are cheap to read and usually survive when the stack trace does not. + try + { + return $"{exception.GetType().FullName}: {exception.Message}" + + $" (stack trace unavailable: {e.GetType().Name})"; + } + catch + { + return ""; + } + } } internal static LogType GetUnityLogType(SentryLevel logLevel) diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index c19a18d8c..2a27eb02f 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; @@ -51,13 +55,36 @@ internal class UnityWebRequestTransport : HttpTransportBase { private readonly SentryUnityOptions _options; - public UnityWebRequestTransport(SentryUnityOptions options) + // This transport opens a connection per envelope. On platforms that raise a system dialog + // when the game reaches for an unavailable network - Nintendo Switch prompts to connect to + // the internet - every attempt made while offline puts that dialog on screen. A game with + // logs and metrics enabled flushes an envelope every few seconds, so a blind retry per + // envelope turns into a steady stream of prompts. Two guards bound that: the reachability + // check skips sending altogether while the platform reports no network, and the backoff + // spaces out attempts on platforms where reachability cannot be trusted. + private const double InitialBackoffSeconds = 1.0; + private const double MaxBackoffSeconds = 60.0; + + private readonly IApplication _application; + + private int _consecutiveConnectionErrors; + 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) { + if (!CanAttemptSend(envelope)) + { + yield break; + } + using var processedEnvelope = ProcessEnvelope(envelope); if (processedEnvelope.Items.Count > 0) { @@ -66,6 +93,14 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) var www = CreateWebRequest(httpRequest); yield return www.SendWebRequest(); + if (www.result == UnityWebRequest.Result.ConnectionError) + { + OnConnectionError(www, processedEnvelope); + yield break; + } + + OnConnectionSucceeded(); + var response = GetResponse(www); if (response is not null) { @@ -74,6 +109,81 @@ internal IEnumerator SendEnvelopeAsync(Envelope envelope) } } + /// + /// Whether the platform reports a usable network. Prefers the platform's own probe where one + /// is available, because Unity's reachability cannot always be trusted - on Nintendo Switch it + /// reports the console as reachable while it is offline. + /// + private bool IsNetworkAvailable() + { + if (_options.NetworkAvailabilityProbe is { } probe) + { + return probe(); + } + + return _application.InternetReachability != NetworkReachability.NotReachable; + } + + /// + /// Whether it is worth opening a connection for this envelope. Envelopes dropped here are + /// recorded as discarded so client reports still account for them. + /// + private bool CanAttemptSend(Envelope envelope) + { + if (!IsNetworkAvailable()) + { + _options.LogDebug("No network available. Dropping envelope instead of attempting to send."); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + return false; + } + + if (_consecutiveConnectionErrors > 0) + { + var remaining = _retryAfterRealtime - Time.realtimeSinceStartupAsDouble; + if (remaining > 0.0) + { + _options.LogDebug( + "Backing off after {0} failed connection attempt(s). Dropping envelope, retrying in {1:F1}s.", + _consecutiveConnectionErrors, remaining); + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + return false; + } + } + + return true; + } + + private void OnConnectionError(UnityWebRequest www, Envelope envelope) + { + _consecutiveConnectionErrors++; + + var backoff = Math.Min( + MaxBackoffSeconds, + InitialBackoffSeconds * Math.Pow(2, _consecutiveConnectionErrors - 1)); + _retryAfterRealtime = Time.realtimeSinceStartupAsDouble + backoff; + + // Reachability is logged here because a platform reporting itself reachable while the + // connection fails is exactly the case the backoff exists to cover. + var backoffDetail = $"{backoff:F1}s (consecutive failure #{_consecutiveConnectionErrors})"; + _options.LogWarning( + "Failed to send request: {0}. Reachability reported as {1}. Backing off for {2}.", + www.error, _application.InternetReachability, backoffDetail); + + _options.ClientReportRecorder.RecordDiscardedEvents(DiscardReason.NetworkError, envelope); + } + + private void OnConnectionSucceeded() + { + if (_consecutiveConnectionErrors == 0) + { + return; + } + + _options.LogDebug("Connection restored after {0} failed attempt(s).", _consecutiveConnectionErrors); + _consecutiveConnectionErrors = 0; + _retryAfterRealtime = 0.0; + } + private UnityWebRequest CreateWebRequest(HttpRequestMessage message) { using var contentStream = ReadStreamFromHttpContent(message.Content); diff --git a/test/Sentry.Unity.Tests/SentryUnityOptionsExtensionsTests.cs b/test/Sentry.Unity.Tests/SentryUnityOptionsExtensionsTests.cs index 8ba703b9b..cf802b657 100644 --- a/test/Sentry.Unity.Tests/SentryUnityOptionsExtensionsTests.cs +++ b/test/Sentry.Unity.Tests/SentryUnityOptionsExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using NUnit.Framework; using Sentry.Unity.Tests.Stubs; @@ -207,4 +208,34 @@ public void IsNativeSupportEnabled_ConsolePlatforms_ReturnsExpectedValue( Assert.AreEqual(expectedResult, result); } + + /// + /// Switch 2 shares the Switch option. It is resolved by name because + /// RuntimePlatform.Switch2 does not exist on the Unity versions the SDK still supports, + /// so this parses the member instead of referencing it and skips where it is unavailable. + /// + [Test] + [TestCase(true, true)] + [TestCase(false, false)] + public void IsNativeSupportEnabled_Switch2_FollowsSwitchOption(bool optionEnabled, bool expectedResult) + { + if (!Enum.TryParse( + SentryUnityOptionsExtensions.Switch2PlatformName, out var switch2)) + { + Assert.Ignore("This Unity version predates 'RuntimePlatform.Switch2'."); + } + + var options = _fixture.GetSut(); + options.SwitchNativeSupportEnabled = optionEnabled; + + Assert.AreEqual(expectedResult, options.IsNativeSupportEnabled(switch2)); + } + + [Test] + public void IsSwitch2_OtherPlatforms_ReturnsFalse() + { + Assert.IsFalse(RuntimePlatform.Switch.IsSwitch2()); + Assert.IsFalse(RuntimePlatform.PS5.IsSwitch2()); + Assert.IsFalse(RuntimePlatform.WindowsPlayer.IsSwitch2()); + } } diff --git a/test/Sentry.Unity.Tests/UnityLoggerTests.cs b/test/Sentry.Unity.Tests/UnityLoggerTests.cs index daad31632..bed5f5167 100644 --- a/test/Sentry.Unity.Tests/UnityLoggerTests.cs +++ b/test/Sentry.Unity.Tests/UnityLoggerTests.cs @@ -1,3 +1,4 @@ +using System; using NUnit.Framework; using Sentry.Unity.Tests.SharedClasses; using UnityEngine; @@ -50,4 +51,52 @@ public void Log_SetsTag() // The format is: "(logType, tag, message)" StringAssert.AreEqualIgnoringCase(UnityLogger.LogTag, testLogger.Logs[0].Item2); } + + /// + /// Callers log from inside `catch` blocks that already handled the failure. If the logger + /// throws there, a handled error becomes a fatal one - which is how SDK initialization aborted + /// on Nintendo Switch, where stringifying certain exceptions throws again. + /// + [Test] + public void Log_ExceptionThrowsWhileBeingStringified_DoesNotPropagateAndStillLogs() + { + var testLogger = new UnityTestLogger(); + var logger = new UnityLogger(new SentryOptions { DiagnosticLevel = SentryLevel.Debug }, testLogger); + + Assert.DoesNotThrow(() => + logger.Log(SentryLevel.Error, "Something failed", new ThrowingToStringException())); + + Assert.AreEqual(1, testLogger.Logs.Count); + var message = testLogger.Logs[0].Item3; + StringAssert.Contains("Something failed", message); + StringAssert.Contains(nameof(ThrowingToStringException), message); + } + + [Test] + public void Log_MessageAndArgumentsDoNotMatch_DoesNotPropagate() + { + var testLogger = new UnityTestLogger(); + var logger = new UnityLogger(new SentryOptions { DiagnosticLevel = SentryLevel.Debug }, testLogger); + + // More placeholders than arguments - string.Format throws on this. + Assert.DoesNotThrow(() => logger.Log(SentryLevel.Debug, "{0} {1} {2}", null, "only-one")); + + Assert.AreEqual(1, testLogger.Logs.Count); + } + + private sealed class ThrowingToStringException : Exception + { + public ThrowingToStringException() + { } + + public ThrowingToStringException(string message) : base(message) + { } + + public ThrowingToStringException(string message, Exception innerException) : base(message, innerException) + { } + + public override string ToString() => throw new InvalidOperationException("cannot stringify"); + + public override string StackTrace => throw new InvalidOperationException("no stack trace here"); + } } diff --git a/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs new file mode 100644 index 000000000..3179edf88 --- /dev/null +++ b/test/Sentry.Unity.Tests/UnityWebRequestTransportTests.cs @@ -0,0 +1,81 @@ +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()); + + /// + /// The coroutine has to bail before its first yield. Anything else means a + /// got created, 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 platform probe has the final say. On Nintendo Switch reachability reports the console as + /// reachable while it is offline, and attempting the send is what raises the system dialog - so + /// a probe saying "no network" has to win over reachability saying otherwise. + /// + [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);