From 023e5d684f16c9f4357648ce168e9c3b609dabde Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 3 Sep 2026 18:36:46 +0200 Subject: [PATCH 1/2] fix(switch): stop the diagnostic logger from throwing while logging Callers log from inside catch blocks that have already handled the failure. UnityLogger interpolated the exception directly, so anything that threw while being stringified escaped from within the handler and turned a handled error into a fatal one. On Nintendo Switch that is what happened: with DisableFileWrite set, resolving the installation ID falls through to NetworkInterface.GetAllNetworkInterfaces(), whose static initializer throws. Hub.ConfigureScope caught it and logged it, but stringifying that exception threw again, escaping the handler and aborting SDK initialization partway through registering integrations. Every Unity integration after GlobalRootScopeIntegration was left unregistered, so nothing was captured and no envelope was ever produced. Format the message and render the exception defensively, falling back to the type and message when the stack trace cannot be read, and never let the write to Unity's logger propagate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ABHTqM37qGAc9iQZcdbdY8 --- CHANGELOG.md | 1 + src/Sentry.Unity/UnityLogger.cs | 54 ++++++++++++++++++++- test/Sentry.Unity.Tests/UnityLoggerTests.cs | 49 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1271dd85f..9eec686f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- 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 ([#2832](https://github.com/getsentry/sentry-unity/pull/2832)) - 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)) ### Dependencies 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/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"); + } } From 87ec471fb65114d559c39e37437a1e52677f63dd Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 16:38:16 +0200 Subject: [PATCH 2/2] cleanup --- CHANGELOG.md | 2 +- src/Sentry.Unity/UnityLogger.cs | 11 ++++------- test/Sentry.Unity.Tests/UnityLoggerTests.cs | 8 ++++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eec686f3..cd63869a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixes -- 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 ([#2832](https://github.com/getsentry/sentry-unity/pull/2832)) +- Hardened the `UnityLogger` to no longer throw when failing to format a log message. ([#2832](https://github.com/getsentry/sentry-unity/pull/2832)) - 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)) ### Dependencies diff --git a/src/Sentry.Unity/UnityLogger.cs b/src/Sentry.Unity/UnityLogger.cs index d0d667856..fc9672b7f 100644 --- a/src/Sentry.Unity/UnityLogger.cs +++ b/src/Sentry.Unity/UnityLogger.cs @@ -31,10 +31,7 @@ public void Log(SentryLevel logLevel, string? message, Exception? exception = nu } // 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. + // inside a `catch` that already handled the failure. string logMessage; try { @@ -42,7 +39,8 @@ public void Log(SentryLevel logLevel, string? message, Exception? exception = nu } catch (Exception e) { - logMessage = $"({logLevel.ToString()}) Failed to format log message: {e.GetType().Name}"; + // Keep the raw message - a bad format string shouldn't erase what the caller wanted to say. + logMessage = $"({logLevel.ToString()}) {message} (formatting failed: {e.GetType().Name})"; } try @@ -57,7 +55,7 @@ public void Log(SentryLevel logLevel, string? message, Exception? exception = nu /// /// Renders an exception without trusting , which walks the - /// stack trace and can throw on platforms with limited stack trace support. + /// stack trace and can throw on platforms with limited stack trace support, i.e. Nintendo Switch. /// private static string Describe(Exception? exception) { @@ -72,7 +70,6 @@ private static string Describe(Exception? exception) } 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}" + diff --git a/test/Sentry.Unity.Tests/UnityLoggerTests.cs b/test/Sentry.Unity.Tests/UnityLoggerTests.cs index bed5f5167..cbd763b07 100644 --- a/test/Sentry.Unity.Tests/UnityLoggerTests.cs +++ b/test/Sentry.Unity.Tests/UnityLoggerTests.cs @@ -53,9 +53,7 @@ public void Log_SetsTag() } /// - /// 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. + /// Callers log from inside `catch` blocks that already handled the failure. /// [Test] public void Log_ExceptionThrowsWhileBeingStringified_DoesNotPropagateAndStillLogs() @@ -73,7 +71,7 @@ public void Log_ExceptionThrowsWhileBeingStringified_DoesNotPropagateAndStillLog } [Test] - public void Log_MessageAndArgumentsDoNotMatch_DoesNotPropagate() + public void Log_MessageAndArgumentsDoNotMatch_DoesNotPropagateAndKeepsTheRawMessage() { var testLogger = new UnityTestLogger(); var logger = new UnityLogger(new SentryOptions { DiagnosticLevel = SentryLevel.Debug }, testLogger); @@ -82,6 +80,8 @@ public void Log_MessageAndArgumentsDoNotMatch_DoesNotPropagate() Assert.DoesNotThrow(() => logger.Log(SentryLevel.Debug, "{0} {1} {2}", null, "only-one")); Assert.AreEqual(1, testLogger.Logs.Count); + // The unformatted message is still worth more than a bare "formatting failed". + StringAssert.Contains("{0} {1} {2}", testLogger.Logs[0].Item3); } private sealed class ThrowingToStringException : Exception