From 14a34b4ed9aeb7f531e3cea61e8b706773883d63 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 29 Oct 2025 17:29:38 +0100 Subject: [PATCH 01/11] Capture message instead of synthetic error exception --- .../UnityApplicationLoggingIntegration.cs | 22 +++++++++++++++++-- .../Integrations/UnityErrorLogException.cs | 17 +++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 52143fe65..424fa02c0 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -1,4 +1,5 @@ using Sentry.Integrations; +using Sentry.Protocol; using UnityEngine; namespace Sentry.Unity.Integrations; @@ -92,8 +93,25 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo { if (_options?.AttachStacktrace is true && !string.IsNullOrEmpty(stacktrace)) { - var ule = new UnityErrorLogException(message, stacktrace, _options); - var sentryEvent = new SentryEvent(ule) { Level = SentryLevel.Error }; + var frames = UnityErrorLogException.ParseStackTrace(stacktrace, _options); + frames.Reverse(); + + var thread = new SentryThread + { + Crashed = false, + Current = true, + Stacktrace = new SentryStackTrace + { + Frames = frames + } + }; + + var sentryEvent = new SentryEvent + { + Message = message, + Level = SentryLevel.Error, + SentryThreads = [thread] + }; _hub.CaptureEvent(sentryEvent); } diff --git a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs index 8d7a6daeb..73b214f05 100644 --- a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs +++ b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs @@ -41,7 +41,7 @@ public SentryException ToSentryException() { _logger?.LogDebug("Creating SentryException out of synthetic ErrorLogException"); - var frames = ParseStackTrace(_logStackTrace); + var frames = ParseStackTrace(_logStackTrace, _options); frames.Reverse(); var stacktrace = new SentryStackTrace { Frames = frames }; @@ -61,7 +61,14 @@ public SentryException ToSentryException() private const string AtFileMarker = " (at "; - private List ParseStackTrace(string stackTrace) + /// + /// Parses a Unity stacktrace string into a list of SentryStackFrames + /// + /// The Unity stacktrace string to parse + /// Sentry options for configuring frame app detection + /// Optional diagnostic logger for error reporting + /// A list of parsed SentryStackFrames + public static List ParseStackTrace(string stackTrace, SentryOptions? options) { // Example: Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) (at UnityLogHandlerIntegration.cs:89) // This follows the following format: @@ -79,10 +86,10 @@ private List ParseStackTrace(string stackTrace) continue; } - var frame = ParseStackFrame(item, _logger); - if (_options is not null) + var frame = ParseStackFrame(item, options?.DiagnosticLogger); + if (options is not null) { - frame.ConfigureAppFrame(_options); + frame.ConfigureAppFrame(options); } frames.Add(frame); } From 303d03b17338baf325cb051491659dce4bad01dc Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 30 Oct 2025 15:02:23 +0100 Subject: [PATCH 02/11] Pull the .NET changes --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index cde2961c3..cc7d90436 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit cde2961c352f8400a95aeb4679c431f9c6c58cca +Subproject commit cc7d904364bb437f6e9eb9225fc0fd2858ef9177 From ad85a5734216767addef0b2da337e34be1c2a4be Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 30 Oct 2025 15:05:07 +0100 Subject: [PATCH 03/11] Stack trace creation tweak --- .../Integrations/UnityApplicationLoggingIntegration.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 424fa02c0..1b828bcfa 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -1,3 +1,5 @@ +using System.Threading; +using Sentry.Extensibility; using Sentry.Integrations; using Sentry.Protocol; using UnityEngine; @@ -91,15 +93,20 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo if (logType is LogType.Error && _options?.CaptureLogErrorEvents is true) { + _options.DiagnosticLogger?.LogDebug("Stacktrace: {0}", stacktrace); + if (_options?.AttachStacktrace is true && !string.IsNullOrEmpty(stacktrace)) { var frames = UnityErrorLogException.ParseStackTrace(stacktrace, _options); frames.Reverse(); + var currentThread = Thread.CurrentThread; var thread = new SentryThread { Crashed = false, Current = true, + Name = currentThread.Name, + Id = currentThread.ManagedThreadId, Stacktrace = new SentryStackTrace { Frames = frames From db1f804291e872bb074913cedc996cc8170cd712 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 30 Oct 2025 16:23:51 +0100 Subject: [PATCH 04/11] Completely replace synthetic log exception with message --- src/Sentry.Unity/Il2CppEventProcessor.cs | 7 - .../UnityApplicationLoggingIntegration.cs | 14 +- .../Integrations/UnityErrorLogException.cs | 179 ------------- .../Integrations/UnityLogEventFactory.cs | 94 +++++++ .../Integrations/UnityStackTraceParser.cs | 128 +++++++++ src/Sentry.Unity/SentryUnityOptions.cs | 1 - ...UnityApplicationLoggingIntegrationTests.cs | 45 ++++ .../UnityErrorLogExceptionTests.cs | 246 ------------------ .../UnityLogEventFactoryTests.cs | 102 ++++++++ .../UnityStackTraceParserTests.cs | 182 +++++++++++++ 10 files changed, 556 insertions(+), 442 deletions(-) delete mode 100644 src/Sentry.Unity/Integrations/UnityErrorLogException.cs create mode 100644 src/Sentry.Unity/Integrations/UnityLogEventFactory.cs create mode 100644 src/Sentry.Unity/Integrations/UnityStackTraceParser.cs delete mode 100644 test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs create mode 100644 test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs create mode 100644 test/Sentry.Unity.Tests/UnityStackTraceParserTests.cs diff --git a/src/Sentry.Unity/Il2CppEventProcessor.cs b/src/Sentry.Unity/Il2CppEventProcessor.cs index 7d525ebc1..8802a5f03 100644 --- a/src/Sentry.Unity/Il2CppEventProcessor.cs +++ b/src/Sentry.Unity/Il2CppEventProcessor.cs @@ -32,13 +32,6 @@ public void Process(Exception incomingException, SentryEvent sentryEvent) { Options.DiagnosticLogger?.LogDebug("Running Unity IL2CPP event exception processor on: Event {0}", sentryEvent.EventId); - // UnityLogException is a synthetic exception created by the LoggingIntegration by parsing the stacktrace provided - // to the SDK as a string. It therefore lacks the necessary data to fetch the native stacktrace and go from there - if (incomingException is UnityErrorLogException) - { - return; - } - var sentryExceptions = sentryEvent.SentryExceptions; if (sentryExceptions == null) { diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 7c222aaf9..3587ae94d 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -86,13 +86,13 @@ private bool IsGettingDebounced(LogType logType) private void ProcessException(string message, string stacktrace, LogType logType) { // LogType.Exception is getting handled by the `UnityLogHandlerIntegration` - // UNLESS we're configured to handle them - i.e. on WebGL + // UNLESS we're configured to process them - i.e. on WebGL if (logType is LogType.Exception && _captureExceptions) { _options.LogDebug("Exception capture has been enabled. Capturing exception through '{0}'.", nameof(UnityApplicationLoggingIntegration)); - var ule = new UnityErrorLogException(message, stacktrace, _options); - _hub?.CaptureException(ule); + var evt = UnityLogEventFactory.CreateExceptionEvent(message, stacktrace, _options); + _hub?.CaptureEvent(evt); } } @@ -107,12 +107,8 @@ private void ProcessError(string message, string stacktrace, LogType logType) if (_options.AttachStacktrace && !string.IsNullOrEmpty(stacktrace)) { - _options.LogDebug("Attaching stacktrace to event."); - - var ule = new UnityErrorLogException(message, stacktrace, _options); - var sentryEvent = new SentryEvent(ule) { Level = SentryLevel.Error }; - - _hub?.CaptureEvent(sentryEvent); + var evt = UnityLogEventFactory.CreateMessageEvent(message, stacktrace, SentryLevel.Error, _options); + _hub?.CaptureEvent(evt); } else { diff --git a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs deleted file mode 100644 index 8dd81e7f2..000000000 --- a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Sentry.Extensibility; -using Sentry.Protocol; -using UnityEngine; - -namespace Sentry.Unity.Integrations -{ - /// - /// An exception raised through the Application Logging Integration - /// - /// - /// - /// - internal class UnityErrorLogException : Exception - { - internal static readonly string ExceptionType = "LogError"; - - private readonly string _logString = string.Empty; - private readonly string _logStackTrace = string.Empty; - - private readonly SentryOptions? _options; - private readonly IDiagnosticLogger? _logger; - - public UnityErrorLogException(string logString, string logStackTrace, SentryOptions? options) - : base(logString) - { - _logString = logString; - _logStackTrace = logStackTrace; - _options = options; - _logger = _options?.DiagnosticLogger; - } - - internal UnityErrorLogException() : base() { } - - private UnityErrorLogException(string message) : base(message) { } - - private UnityErrorLogException(string message, Exception innerException) : base(message, innerException) { } - - public SentryException ToSentryException() - { - _logger?.LogDebug("Creating SentryException out of synthetic ErrorLogException"); - - var frames = ParseStackTrace(_logStackTrace, _options); - frames.Reverse(); - - var stacktrace = new SentryStackTrace { Frames = frames }; - - return new SentryException - { - Stacktrace = stacktrace, - Value = _logString, - Type = ExceptionType, - Mechanism = new Mechanism - { - Handled = true, - Type = "unity.log" - } - }; - } - - private const string AtFileMarker = " (at "; - - /// - /// Parses a Unity stacktrace string into a list of SentryStackFrames - /// - /// The Unity stacktrace string to parse - /// Sentry options for configuring frame app detection - /// Optional diagnostic logger for error reporting - /// A list of parsed SentryStackFrames - public static List ParseStackTrace(string stackTrace, SentryOptions? options) - { - // Example: Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) (at UnityLogHandlerIntegration.cs:89) - // This follows the following format: - // Module.Class.Method[.Invoke] (arguments) (at filepath:linenumber) - // The ':linenumber' is optional and will be omitted in builds - - var frames = new List(); - var stackList = stackTrace.Split('\n'); - - foreach (var line in stackList) - { - var item = line.TrimEnd('\r'); - if (string.IsNullOrEmpty(item)) - { - continue; - } - - var frame = ParseStackFrame(item, options?.DiagnosticLogger); - if (options is not null) - { - frame.ConfigureAppFrame(options); - } - frames.Add(frame); - } - - return frames; - } - - private static SentryStackFrame ParseStackFrame(string stackFrameLine, IDiagnosticLogger? logger = null) - { - var closingParenthesis = stackFrameLine.IndexOf(')'); - if (closingParenthesis == -1) - { - return CreateBasicStackFrame(stackFrameLine); - } - - try - { - var functionName = stackFrameLine.Substring(0, closingParenthesis + 1); - var remainingText = stackFrameLine.Substring(closingParenthesis + 1); - - if (!remainingText.StartsWith(AtFileMarker)) - { - // If it does not start with '(at' it's an unknown format. We're falling back to a basic stackframe - return CreateBasicStackFrame(stackFrameLine); - } - - var (filename, lineNo) = ParseFileLocation(remainingText); - var filenameWithoutZeroes = StripZeroes(filename); - - return new SentryStackFrame - { - FileName = TryResolveFileNameForMono(filenameWithoutZeroes), - AbsolutePath = filenameWithoutZeroes, - Function = functionName, - LineNumber = lineNo == -1 ? null : lineNo - }; - } - catch (Exception e) - { - logger?.LogError(e, "Failed to parse the stack frame line {0}", stackFrameLine); - - // Suppress any errors while parsing and fall back to a basic stackframe - return CreateBasicStackFrame(stackFrameLine); - } - } - - private static (string Filename, int LineNo) ParseFileLocation(string location) - { - // Remove " (at " prefix and trailing ")" - var fileInfo = location.Substring(AtFileMarker.Length, location.Length - AtFileMarker.Length - 1); - var lastColon = fileInfo.LastIndexOf(':'); - - return lastColon == -1 - ? (fileInfo, -1) - : (fileInfo.Substring(0, lastColon), int.Parse(fileInfo.Substring(lastColon + 1))); - } - - private static SentryStackFrame CreateBasicStackFrame(string functionName) => new() - { - Function = functionName, - FileName = null, - AbsolutePath = null, - LineNumber = null - }; - - // https://github.com/getsentry/sentry-unity/issues/103 - private static string StripZeroes(string filename) - => filename.Replace("0", "").Equals("<>", StringComparison.OrdinalIgnoreCase) - ? string.Empty - : filename; - - private static string TryResolveFileNameForMono(string fileName) - { - try - { - // throws on Mono for <1231231231> paths - return Path.GetFileName(fileName); - } - catch - { - // mono path - return "Unknown"; - } - } - } -} diff --git a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs new file mode 100644 index 000000000..c1e34663a --- /dev/null +++ b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Sentry.Protocol; + +namespace Sentry.Unity.Integrations; + +/// +/// Factory for creating SentryEvent objects from Unity log messages and stacktraces +/// +internal static class UnityLogEventFactory +{ + /// + /// Creates a message event with stacktrace attached via threads (for Debug.LogError) + /// + /// The log message + /// The Unity stacktrace string + /// The Sentry event level + /// Sentry Unity options + /// A SentryEvent with the message and stacktrace as threads + public static SentryEvent CreateMessageEvent( + string message, + string stackTrace, + SentryLevel level, + SentryUnityOptions options) + { + var frames = UnityStackTraceParser.Parse(stackTrace, options); + frames.Reverse(); + + var thread = CreateThreadFromStackTrace(frames); + + return new SentryEvent + { + Message = message, + Level = level, + SentryThreads = [thread] + }; + } + + /// + /// Creates an exception event from Unity log data (for exceptions on WebGL) + /// + /// The log message + /// The Unity stacktrace string + /// Sentry Unity options + /// A SentryEvent with a synthetic exception + public static SentryEvent CreateExceptionEvent( + string message, + string stackTrace, + SentryUnityOptions options) + { + var frames = UnityStackTraceParser.Parse(stackTrace, options); + frames.Reverse(); + + var sentryException = CreateUnityLogException(message, frames); + + return new SentryEvent(new Exception(message)) + { + SentryExceptions = [sentryException], + Level = SentryLevel.Error + }; + } + + private static SentryThread CreateThreadFromStackTrace(List frames) + { + var currentThread = Thread.CurrentThread; + return new SentryThread + { + Crashed = false, + Current = true, + Name = currentThread.Name, + Id = currentThread.ManagedThreadId, + Stacktrace = new SentryStackTrace { Frames = frames } + }; + } + + private static SentryException CreateUnityLogException( + string message, + List frames, + string exceptionType = "LogError") + { + return new SentryException + { + Stacktrace = new SentryStackTrace { Frames = frames }, + Value = message, + Type = exceptionType, + Mechanism = new Mechanism + { + Handled = true, + Type = "unity.log" + } + }; + } +} diff --git a/src/Sentry.Unity/Integrations/UnityStackTraceParser.cs b/src/Sentry.Unity/Integrations/UnityStackTraceParser.cs new file mode 100644 index 000000000..730817369 --- /dev/null +++ b/src/Sentry.Unity/Integrations/UnityStackTraceParser.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Sentry.Extensibility; +using Sentry.Protocol; + +namespace Sentry.Unity.Integrations; + +/// +/// Parses Unity-formatted stacktraces into Sentry stack frames +/// +internal static class UnityStackTraceParser +{ + private const string AtFileMarker = " (at "; + + /// + /// Parses a Unity stacktrace string into structured SentryStackFrames + /// + /// The Unity stacktrace string to parse + /// Sentry options for configuring frame app detection + /// A list of parsed SentryStackFrames + public static List Parse(string stackTrace, SentryOptions? options) + { + // Example: Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) (at UnityLogHandlerIntegration.cs:89) + // This follows the following format: + // Module.Class.Method[.Invoke] (arguments) (at filepath:linenumber) + // The ':linenumber' is optional and will be omitted in builds + + var frames = new List(); + var stackList = stackTrace.Split('\n'); + + foreach (var line in stackList) + { + var item = line.TrimEnd('\r'); + if (string.IsNullOrEmpty(item)) + { + continue; + } + + var frame = ParseStackFrame(item, options?.DiagnosticLogger); + if (options is not null) + { + frame.ConfigureAppFrame(options); + } + frames.Add(frame); + } + + return frames; + } + + private static SentryStackFrame ParseStackFrame(string stackFrameLine, IDiagnosticLogger? logger = null) + { + var closingParenthesis = stackFrameLine.IndexOf(')'); + if (closingParenthesis == -1) + { + return CreateBasicStackFrame(stackFrameLine); + } + + try + { + var functionName = stackFrameLine.Substring(0, closingParenthesis + 1); + var remainingText = stackFrameLine.Substring(closingParenthesis + 1); + + if (!remainingText.StartsWith(AtFileMarker)) + { + // If it does not start with '(at' it's an unknown format. We're falling back to a basic stackframe + return CreateBasicStackFrame(stackFrameLine); + } + + var (filename, lineNo) = ParseFileLocation(remainingText); + var filenameWithoutZeroes = StripZeroes(filename); + + return new SentryStackFrame + { + FileName = TryResolveFileNameForMono(filenameWithoutZeroes), + AbsolutePath = filenameWithoutZeroes, + Function = functionName, + LineNumber = lineNo == -1 ? null : lineNo + }; + } + catch (Exception e) + { + logger?.LogError(e, "Failed to parse the stack frame line {0}", stackFrameLine); + + // Suppress any errors while parsing and fall back to a basic stackframe + return CreateBasicStackFrame(stackFrameLine); + } + } + + private static (string Filename, int LineNo) ParseFileLocation(string location) + { + // Remove " (at " prefix and trailing ")" + var fileInfo = location.Substring(AtFileMarker.Length, location.Length - AtFileMarker.Length - 1); + var lastColon = fileInfo.LastIndexOf(':'); + + return lastColon == -1 + ? (fileInfo, -1) + : (fileInfo.Substring(0, lastColon), int.Parse(fileInfo.Substring(lastColon + 1))); + } + + private static SentryStackFrame CreateBasicStackFrame(string functionName) => new() + { + Function = functionName, + FileName = null, + AbsolutePath = null, + LineNumber = null + }; + + // https://github.com/getsentry/sentry-unity/issues/103 + private static string StripZeroes(string filename) + => filename.Replace("0", "").Equals("<>", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : filename; + + private static string TryResolveFileNameForMono(string fileName) + { + try + { + // throws on Mono for <1231231231> paths + return Path.GetFileName(fileName); + } + catch + { + // mono path + return "Unknown"; + } + } +} diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 2555296a4..3dcea1ff4 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -343,7 +343,6 @@ internal SentryUnityOptions(IApplication? application = null, var processor = new UnityEventProcessor(this); AddEventProcessor(processor); AddTransactionProcessor(processor); - AddExceptionProcessor(new UnityExceptionProcessor()); // UnityLogHandlerIntegration is not compatible with WebGL, so it's added conditionally if (application.Platform != RuntimePlatform.WebGLPlayer) diff --git a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs index 7654d1644..ab88dff6c 100644 --- a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs @@ -248,5 +248,50 @@ public void OnLogMessageReceived_ExceptionType_NoBreadcrumbAdded() // Exception breadcrumbs are handled by the .NET SDK, not by this integration Assert.AreEqual(0, _fixture.Hub.ConfigureScopeCalls.Count); } + + [Test] + public void OnLogMessageReceived_LogErrorWithStacktrace_CapturesAsMessageWithThreads() + { + _fixture.SentryOptions.AttachStacktrace = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + var stacktrace = "BugFarmButtons:LogError () (at Assets/Scripts/BugFarmButtons.cs:85)"; + + sut.OnLogMessageReceived(message, stacktrace, LogType.Error); + + Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); + var capturedEvent = _fixture.Hub.CapturedEvents[0]; + + // Verify it's a message event, not an exception event + Assert.NotNull(capturedEvent.Message); + Assert.AreEqual(message, capturedEvent.Message!.Message); + Assert.IsEmpty(capturedEvent.SentryExceptions); + + // Verify stacktrace is attached via threads + Assert.NotNull(capturedEvent.SentryThreads); + var thread = capturedEvent.SentryThreads.Single(); + Assert.NotNull(thread.Stacktrace); + Assert.NotNull(thread.Stacktrace!.Frames); + Assert.Greater(thread.Stacktrace.Frames.Count, 0); + } + + [Test] + public void OnLogMessageReceived_LogErrorWithoutStacktrace_CapturesAsSimpleMessage() + { + _fixture.SentryOptions.AttachStacktrace = false; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, "stacktrace", LogType.Error); + + Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); + var capturedEvent = _fixture.Hub.CapturedEvents[0]; + + // Verify it's a simple message without threads + Assert.NotNull(capturedEvent.Message); + Assert.AreEqual(message, capturedEvent.Message!.Message); + Assert.IsEmpty(capturedEvent.SentryExceptions); + Assert.IsEmpty(capturedEvent.SentryThreads); + } } } diff --git a/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs b/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs deleted file mode 100644 index 0ce5990d7..000000000 --- a/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using Sentry.Protocol; -using Sentry.Unity.Integrations; - -namespace Sentry.Unity.Tests; - -public class UnityErrorLogExceptionTests -{ - [Test] - public void ToSentryException_MarkedAsHandled() - { - var sentryException = new UnityErrorLogException("", "", new SentryUnityOptions()).ToSentryException(); - - Assert.IsTrue(sentryException.Mechanism?.Handled); - } - - [TestCaseSource(nameof(ParsingTestCases))] - public void ToSentryException_ParsingTestCases( - string logString, - string logStackTrace, - SentryException sentryException) - { - var actual = new UnityErrorLogException(logString, logStackTrace, new SentryUnityOptions()).ToSentryException(); - - AssertEqual(sentryException, actual); - } - - private static readonly object[] ParsingTestCases = - [ - // An example log message + stacktrace from within the Editor - new object[] - { - "Debug.LogError() called", - """ - UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) - Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) (at /Users/bitfox/Workspace/sentry-unity/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs:89) - UnityEngine.Debug:LogError (object) - BugFarmButtons:LogError () (at Assets/Scripts/BugFarmButtons.cs:85) - UnityEngine.EventSystems.EventSystem:Update () (at ./Library/PackageCache/com.unity.ugui/Runtime/UGUI/EventSystem/EventSystem.cs:530) - """, - new SentryException - { - Value = "Debug.LogError() called", - Type = UnityErrorLogException.ExceptionType, - Stacktrace = new SentryStackTrace - { - Frames = new List - { - new() - { - Function = "UnityEngine.EventSystems.EventSystem:Update ()", - AbsolutePath = "./Library/PackageCache/com.unity.ugui/Runtime/UGUI/EventSystem/EventSystem.cs", - LineNumber = 530, - FileName = "EventSystem.cs", - InApp = false - }, - new() - { - Function = "BugFarmButtons:LogError ()", - AbsolutePath = "Assets/Scripts/BugFarmButtons.cs", - LineNumber = 85, - FileName = "BugFarmButtons.cs", - InApp = true - }, - new() - { - Function = "UnityEngine.Debug:LogError (object)", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - new() - { - Function = "Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", - AbsolutePath = "/Users/bitfox/Workspace/sentry-unity/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs", - LineNumber = 89, - FileName = "UnityLogHandlerIntegration.cs", - InApp = false - }, - new() - { - Function = "UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - } - } - }, - Mechanism = new Mechanism - { - Handled = true, - Type = "unity.log" - } - } - }, - // An example log message + stacktrace from a IL2CPP release build - new object[] - { - "LogError from within the StackTraceSample", - """ - UnityEngine.DebugLogHandler:Internal_Log(LogType, LogOption, String, Object) - BugFarmButtons:StackTraceExampleB() - BugFarmButtons:StackTraceExampleA() - UnityEngine.Events.UnityEvent:Invoke() - UnityEngine.EventSystems.ExecuteEvents:Execute(GameObject, BaseEventData, EventFunction`1) - UnityEngine.EventSystems.StandaloneInputModule:ReleaseMouse(PointerEventData, GameObject) - UnityEngine.EventSystems.StandaloneInputModule:ProcessMouseEvent(Int32) - UnityEngine.EventSystems.StandaloneInputModule:Process() - - """, - new SentryException - { - Value = "LogError from within the StackTraceSample", - Type = UnityErrorLogException.ExceptionType, - Stacktrace = new SentryStackTrace - { - Frames = new List - { - new() - { - Function = "UnityEngine.EventSystems.StandaloneInputModule:Process()", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - new() - { - Function = "UnityEngine.EventSystems.StandaloneInputModule:ProcessMouseEvent(Int32)", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - new() - { - Function = "UnityEngine.EventSystems.StandaloneInputModule:ReleaseMouse(PointerEventData, GameObject)", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - new() - { - Function = "UnityEngine.EventSystems.ExecuteEvents:Execute(GameObject, BaseEventData, EventFunction`1)", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - new() - { - Function = "UnityEngine.Events.UnityEvent:Invoke()", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - new() - { - Function = "BugFarmButtons:StackTraceExampleA()", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = true - }, - new() - { - Function = "BugFarmButtons:StackTraceExampleB()", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = true - }, - new() - { - Function = "UnityEngine.DebugLogHandler:Internal_Log(LogType, LogOption, String, Object)", - AbsolutePath = null, - LineNumber = null, - FileName = null, - InApp = false - }, - } - }, - Mechanism = new Mechanism - { - Handled = true, - Type = "unity.log" - } - } - } - ]; - - private static void AssertEqual(SentryException expected, SentryException actual) - { - Assert.AreEqual(expected.Value, actual.Value); - Assert.AreEqual(expected.ThreadId, actual.ThreadId); - Assert.AreEqual(expected.Module, actual.Module); - Assert.AreEqual(expected.Type, actual.Type); - if (expected.Stacktrace is not null) - { - Assert.AreEqual(expected.Stacktrace.Frames.Count, actual.Stacktrace!.Frames.Count); - for (var i = 0; i < expected.Stacktrace.Frames.Count; i++) - { - Assert.AreEqual(expected.Stacktrace.Frames[i].Function, actual.Stacktrace.Frames[i].Function); - Assert.AreEqual(expected.Stacktrace.Frames[i].Module, actual.Stacktrace.Frames[i].Module); - Assert.AreEqual(expected.Stacktrace.Frames[i].Package, actual.Stacktrace.Frames[i].Package); - Assert.AreEqual(expected.Stacktrace.Frames[i].Platform, actual.Stacktrace.Frames[i].Platform); - Assert.AreEqual(expected.Stacktrace.Frames[i].AbsolutePath, actual.Stacktrace.Frames[i].AbsolutePath); - Assert.AreEqual(expected.Stacktrace.Frames[i].ColumnNumber, actual.Stacktrace.Frames[i].ColumnNumber); - Assert.AreEqual(expected.Stacktrace.Frames[i].FileName, actual.Stacktrace.Frames[i].FileName); - Assert.AreEqual(expected.Stacktrace.Frames[i].ImageAddress, actual.Stacktrace.Frames[i].ImageAddress); - Assert.AreEqual(expected.Stacktrace.Frames[i].InApp, actual.Stacktrace.Frames[i].InApp); - Assert.AreEqual(expected.Stacktrace.Frames[i].InstructionAddress, actual.Stacktrace.Frames[i].InstructionAddress); - Assert.AreEqual(expected.Stacktrace.Frames[i].LineNumber, actual.Stacktrace.Frames[i].LineNumber); - Assert.AreEqual(expected.Stacktrace.Frames[i].PostContext, actual.Stacktrace.Frames[i].PostContext); - Assert.AreEqual(expected.Stacktrace.Frames[i].PreContext, actual.Stacktrace.Frames[i].PreContext); - Assert.AreEqual(expected.Stacktrace.Frames[i].SymbolAddress, actual.Stacktrace.Frames[i].SymbolAddress); - } - } - else - { - Assert.Null(actual.Stacktrace); - } - if (expected.Mechanism is not null) - { - Assert.AreEqual(expected.Mechanism.Description, actual.Mechanism!.Description); - Assert.AreEqual(expected.Mechanism.Handled, actual.Mechanism.Handled); - Assert.AreEqual(expected.Mechanism.Type, actual.Mechanism.Type); - Assert.AreEqual(expected.Mechanism.HelpLink, actual.Mechanism.HelpLink); - Assert.AreEqual(expected.Mechanism.Data, actual.Mechanism.Data); - Assert.True(expected.Mechanism.Data.Keys.SequenceEqual(actual.Mechanism.Data.Keys)); - Assert.True(expected.Mechanism.Data.Values.SequenceEqual(actual.Mechanism.Data.Values)); - Assert.True(expected.Mechanism.Meta.Keys.SequenceEqual(actual.Mechanism.Meta.Keys)); - Assert.True(expected.Mechanism.Meta.Values.SequenceEqual(actual.Mechanism.Meta.Values)); - } - else - { - Assert.Null(actual.Mechanism); - } - } -} diff --git a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs new file mode 100644 index 000000000..888df77fc --- /dev/null +++ b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs @@ -0,0 +1,102 @@ +using System.Linq; +using NUnit.Framework; +using Sentry.Unity.Integrations; + +namespace Sentry.Unity.Tests; + +public class UnityLogEventFactoryTests +{ + private const string SampleMessage = "Debug.LogError() called"; + private const string SampleStackTrace = """ + UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) + BugFarmButtons:LogError () (at Assets/Scripts/BugFarmButtons.cs:85) + """; + + [Test] + public void CreateMessageEvent_ValidStackTrace_CreatesMessageEventWithThreads() + { + var evt = UnityLogEventFactory.CreateMessageEvent( + SampleMessage, SampleStackTrace, SentryLevel.Error, new SentryUnityOptions()); + + Assert.NotNull(evt.Message); + Assert.AreEqual(SampleMessage, evt.Message!.Message); + Assert.AreEqual(SentryLevel.Error, evt.Level); + Assert.NotNull(evt.SentryThreads); + Assert.AreEqual(1, evt.SentryThreads.Count()); + } + + [Test] + public void CreateMessageEvent_ValidStackTrace_ThreadHasStackTrace() + { + var evt = UnityLogEventFactory.CreateMessageEvent( + SampleMessage, SampleStackTrace, SentryLevel.Error, new SentryUnityOptions()); + + var thread = evt.SentryThreads!.First(); + Assert.False(thread.Crashed); + Assert.True(thread.Current); + Assert.NotNull(thread.Stacktrace); + Assert.NotNull(thread.Stacktrace!.Frames); + Assert.AreEqual(2, thread.Stacktrace.Frames.Count); + } + + [Test] + public void CreateMessageEvent_ValidStackTrace_FramesAreReversed() + { + var evt = UnityLogEventFactory.CreateMessageEvent( + SampleMessage, SampleStackTrace, SentryLevel.Error, new SentryUnityOptions()); + + var frames = evt.SentryThreads!.First().Stacktrace!.Frames; + // After reversing, the last frame in the Unity stacktrace should be first + Assert.AreEqual("BugFarmButtons:LogError ()", frames[0].Function); + Assert.AreEqual("UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", frames[1].Function); + } + + [Test] + public void CreateExceptionEvent_ValidStackTrace_CreatesExceptionEvent() + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + SampleMessage, SampleStackTrace, new SentryUnityOptions()); + + Assert.AreEqual(SentryLevel.Error, evt.Level); + Assert.NotNull(evt.SentryExceptions); + Assert.AreEqual(1, evt.SentryExceptions.Count()); + } + + [Test] + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasCorrectProperties() + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + SampleMessage, SampleStackTrace, new SentryUnityOptions()); + + var exception = evt.SentryExceptions!.First(); + Assert.AreEqual(SampleMessage, exception.Value); + Assert.AreEqual("LogError", exception.Type); + Assert.NotNull(exception.Mechanism); + Assert.True(exception.Mechanism!.Handled); + Assert.AreEqual("unity.log", exception.Mechanism.Type); + } + + [Test] + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasStackTrace() + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + SampleMessage, SampleStackTrace, new SentryUnityOptions()); + + var exception = evt.SentryExceptions!.First(); + Assert.NotNull(exception.Stacktrace); + Assert.NotNull(exception.Stacktrace!.Frames); + Assert.AreEqual(2, exception.Stacktrace.Frames.Count); + } + + [Test] + public void CreateExceptionEvent_ValidStackTrace_FramesAreReversed() + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + SampleMessage, SampleStackTrace, new SentryUnityOptions()); + + var frames = evt.SentryExceptions!.First().Stacktrace!.Frames; + // After reversing, the last frame in the Unity stacktrace should be first + Assert.AreEqual("BugFarmButtons:LogError ()", frames[0].Function); + Assert.AreEqual("UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", frames[1].Function); + } +} diff --git a/test/Sentry.Unity.Tests/UnityStackTraceParserTests.cs b/test/Sentry.Unity.Tests/UnityStackTraceParserTests.cs new file mode 100644 index 000000000..9d5f3bbcd --- /dev/null +++ b/test/Sentry.Unity.Tests/UnityStackTraceParserTests.cs @@ -0,0 +1,182 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Sentry.Protocol; +using Sentry.Unity.Integrations; + +namespace Sentry.Unity.Tests; + +public class UnityStackTraceParserTests +{ + [TestCaseSource(nameof(ParsingTestCases))] + public void Parse_VariousStackTraceFormats_ParsesCorrectly( + string logStackTrace, + List expectedFrames) + { + var actual = UnityStackTraceParser.Parse(logStackTrace, new SentryUnityOptions()); + + Assert.AreEqual(expectedFrames.Count, actual.Count); + for (var i = 0; i < expectedFrames.Count; i++) + { + AssertFrameEqual(expectedFrames[i], actual[i]); + } + } + + private static readonly object[] ParsingTestCases = + [ + // An example log message + stacktrace from within the Editor + new object[] + { + """ + UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) + Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[]) (at /Users/bitfox/Workspace/sentry-unity/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs:89) + UnityEngine.Debug:LogError (object) + BugFarmButtons:LogError () (at Assets/Scripts/BugFarmButtons.cs:85) + UnityEngine.EventSystems.EventSystem:Update () (at ./Library/PackageCache/com.unity.ugui/Runtime/UGUI/EventSystem/EventSystem.cs:530) + """, + new List + { + new() + { + Function = "UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "Sentry.Unity.Integrations.UnityLogHandlerIntegration:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", + AbsolutePath = "/Users/bitfox/Workspace/sentry-unity/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs", + LineNumber = 89, + FileName = "UnityLogHandlerIntegration.cs", + InApp = false + }, + new() + { + Function = "UnityEngine.Debug:LogError (object)", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "BugFarmButtons:LogError ()", + AbsolutePath = "Assets/Scripts/BugFarmButtons.cs", + LineNumber = 85, + FileName = "BugFarmButtons.cs", + InApp = true + }, + new() + { + Function = "UnityEngine.EventSystems.EventSystem:Update ()", + AbsolutePath = "./Library/PackageCache/com.unity.ugui/Runtime/UGUI/EventSystem/EventSystem.cs", + LineNumber = 530, + FileName = "EventSystem.cs", + InApp = false + } + } + }, + // An example log message + stacktrace from a IL2CPP release build + new object[] + { + """ + UnityEngine.DebugLogHandler:Internal_Log(LogType, LogOption, String, Object) + BugFarmButtons:StackTraceExampleB() + BugFarmButtons:StackTraceExampleA() + UnityEngine.Events.UnityEvent:Invoke() + UnityEngine.EventSystems.ExecuteEvents:Execute(GameObject, BaseEventData, EventFunction`1) + UnityEngine.EventSystems.StandaloneInputModule:ReleaseMouse(PointerEventData, GameObject) + UnityEngine.EventSystems.StandaloneInputModule:ProcessMouseEvent(Int32) + UnityEngine.EventSystems.StandaloneInputModule:Process() + + """, + new List + { + new() + { + Function = "UnityEngine.DebugLogHandler:Internal_Log(LogType, LogOption, String, Object)", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "BugFarmButtons:StackTraceExampleB()", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = true + }, + new() + { + Function = "BugFarmButtons:StackTraceExampleA()", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = true + }, + new() + { + Function = "UnityEngine.Events.UnityEvent:Invoke()", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "UnityEngine.EventSystems.ExecuteEvents:Execute(GameObject, BaseEventData, EventFunction`1)", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "UnityEngine.EventSystems.StandaloneInputModule:ReleaseMouse(PointerEventData, GameObject)", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "UnityEngine.EventSystems.StandaloneInputModule:ProcessMouseEvent(Int32)", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + }, + new() + { + Function = "UnityEngine.EventSystems.StandaloneInputModule:Process()", + AbsolutePath = null, + LineNumber = null, + FileName = null, + InApp = false + } + } + } + ]; + + private static void AssertFrameEqual(SentryStackFrame expected, SentryStackFrame actual) + { + Assert.AreEqual(expected.Function, actual.Function); + Assert.AreEqual(expected.Module, actual.Module); + Assert.AreEqual(expected.Package, actual.Package); + Assert.AreEqual(expected.Platform, actual.Platform); + Assert.AreEqual(expected.AbsolutePath, actual.AbsolutePath); + Assert.AreEqual(expected.ColumnNumber, actual.ColumnNumber); + Assert.AreEqual(expected.FileName, actual.FileName); + Assert.AreEqual(expected.ImageAddress, actual.ImageAddress); + Assert.AreEqual(expected.InApp, actual.InApp); + Assert.AreEqual(expected.InstructionAddress, actual.InstructionAddress); + Assert.AreEqual(expected.LineNumber, actual.LineNumber); + Assert.AreEqual(expected.PostContext, actual.PostContext); + Assert.AreEqual(expected.PreContext, actual.PreContext); + Assert.AreEqual(expected.SymbolAddress, actual.SymbolAddress); + } +} From 1afda74fa76705df280e92e9fe060bd0e598fdbf Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 30 Oct 2025 16:42:07 +0100 Subject: [PATCH 05/11] Naming --- .../UnityApplicationLoggingIntegrationTests.cs | 7 ++----- test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs index ab88dff6c..4f6ba8460 100644 --- a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs @@ -250,7 +250,7 @@ public void OnLogMessageReceived_ExceptionType_NoBreadcrumbAdded() } [Test] - public void OnLogMessageReceived_LogErrorWithStacktrace_CapturesAsMessageWithThreads() + public void OnLogMessageReceived_LogErrorAttachStackTraceTrue_CapturesMessageWithThread() { _fixture.SentryOptions.AttachStacktrace = true; var sut = _fixture.GetSut(); @@ -262,12 +262,10 @@ public void OnLogMessageReceived_LogErrorWithStacktrace_CapturesAsMessageWithThr Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); var capturedEvent = _fixture.Hub.CapturedEvents[0]; - // Verify it's a message event, not an exception event Assert.NotNull(capturedEvent.Message); Assert.AreEqual(message, capturedEvent.Message!.Message); Assert.IsEmpty(capturedEvent.SentryExceptions); - // Verify stacktrace is attached via threads Assert.NotNull(capturedEvent.SentryThreads); var thread = capturedEvent.SentryThreads.Single(); Assert.NotNull(thread.Stacktrace); @@ -276,7 +274,7 @@ public void OnLogMessageReceived_LogErrorWithStacktrace_CapturesAsMessageWithThr } [Test] - public void OnLogMessageReceived_LogErrorWithoutStacktrace_CapturesAsSimpleMessage() + public void OnLogMessageReceived_LogErrorAttachStackTraceFalse_CaptureMessageWithNoStackTrace() { _fixture.SentryOptions.AttachStacktrace = false; var sut = _fixture.GetSut(); @@ -287,7 +285,6 @@ public void OnLogMessageReceived_LogErrorWithoutStacktrace_CapturesAsSimpleMessa Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); var capturedEvent = _fixture.Hub.CapturedEvents[0]; - // Verify it's a simple message without threads Assert.NotNull(capturedEvent.Message); Assert.AreEqual(message, capturedEvent.Message!.Message); Assert.IsEmpty(capturedEvent.SentryExceptions); diff --git a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs index 888df77fc..5e73f8751 100644 --- a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs @@ -63,7 +63,7 @@ public void CreateExceptionEvent_ValidStackTrace_CreatesExceptionEvent() } [Test] - public void CreateExceptionEvent_ValidStackTrace_ExceptionHasCorrectProperties() + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasExpectedProperties() { var evt = UnityLogEventFactory.CreateExceptionEvent( SampleMessage, SampleStackTrace, new SentryUnityOptions()); From 6df9de27edaf726a4fe1bdf985a5fa43e9de08cf Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 30 Oct 2025 18:08:50 +0100 Subject: [PATCH 06/11] Updated CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d26855c93..1ed66f72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ - The SDK no longer ends sessions as crashed when capturing unhandled or logged exceptions. Instead, sessions get correctly marked as `SessionEndStatus.Unhandled` ([#2376](https://github.com/getsentry/sentry-unity/pull/2376)) - Added support for Structured Logging. The `SentrySdk.Logger` API is now exposed for Unity users, enabling structured log capture. The SDK can also automatically capture and send Debug logs based on the options configured. ([#2368](https://github.com/getsentry/sentry-unity/pull/2368)) +### Fixes + +- When configured, the SDK now no longer treats `Debug.LogError` events as exceptions but resports them as message events instead ([#2377](https://github.com/getsentry/sentry-unity/pull/2377)) + ### Dependencies - Bump CLI from v2.56.0 to v2.56.1 ([#2356](https://github.com/getsentry/sentry-unity/pull/2356)) From 90224cba8cf7d2644a74b502c88b381f05d141b6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 31 Oct 2025 11:15:37 +0100 Subject: [PATCH 07/11] Updated Debug.LogError test --- test/Sentry.Unity.Tests/IntegrationTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/Sentry.Unity.Tests/IntegrationTests.cs b/test/Sentry.Unity.Tests/IntegrationTests.cs index 23f9def4f..e2cf10eab 100644 --- a/test/Sentry.Unity.Tests/IntegrationTests.cs +++ b/test/Sentry.Unity.Tests/IntegrationTests.cs @@ -222,11 +222,12 @@ public IEnumerator DebugLogException_IsMarkedUnhandled() } [UnityTest] - public IEnumerator DebugLogError_OnMainThread_IsCapturedAndIsMainThreadIsTrue() + public IEnumerator DebugLogError_IsCaptured() { yield return SetupSceneCoroutine("1_BugFarm"); - var expectedAttribute = CreateAttribute("unity.is_main_thread", "true"); + // `Debug.LogError is getting captured as message + _identifyingEventValueAttribute = CreateAttribute("message", _eventMessage); using var _ = InitSentrySdk(); var testBehaviour = new GameObject("TestHolder").AddComponent(); @@ -235,7 +236,6 @@ public IEnumerator DebugLogError_OnMainThread_IsCapturedAndIsMainThreadIsTrue() var triggeredEvent = _testHttpClientHandler.GetEvent(_identifyingEventValueAttribute, _eventReceiveTimeout); Assert.That(triggeredEvent, Does.Contain(_identifyingEventValueAttribute)); - Assert.That(triggeredEvent, Does.Contain(expectedAttribute)); } [UnityTest] @@ -342,6 +342,8 @@ internal IDisposable InitSentrySdk(Action? configure = null) { options.Dsn = "https://e9ee299dbf554dfd930bc5f3c90d5d4b@o447951.ingest.sentry.io/4504604988538880"; options.CreateHttpMessageHandler = () => _testHttpClientHandler; + options.DisableAnrIntegration(); + options.AutoSessionTracking = false; configure?.Invoke(options); }); From b608e71c5d8c4042ce9b8d28990feeefb78d838a Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 31 Oct 2025 13:16:57 +0100 Subject: [PATCH 08/11] Restored defaults --- test/Sentry.Unity.Tests/IntegrationTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/Sentry.Unity.Tests/IntegrationTests.cs b/test/Sentry.Unity.Tests/IntegrationTests.cs index e2cf10eab..df3f1d34e 100644 --- a/test/Sentry.Unity.Tests/IntegrationTests.cs +++ b/test/Sentry.Unity.Tests/IntegrationTests.cs @@ -342,8 +342,6 @@ internal IDisposable InitSentrySdk(Action? configure = null) { options.Dsn = "https://e9ee299dbf554dfd930bc5f3c90d5d4b@o447951.ingest.sentry.io/4504604988538880"; options.CreateHttpMessageHandler = () => _testHttpClientHandler; - options.DisableAnrIntegration(); - options.AutoSessionTracking = false; configure?.Invoke(options); }); From b33350434b2b518781de1693fc31e7b606ad1072 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 11:55:54 +0100 Subject: [PATCH 09/11] Testing --- .../Integrations/UnityLogEventFactory.cs | 35 +++++++------------ test/Sentry.Unity.Tests/IntegrationTests.cs | 10 +++--- .../UnityLogEventFactoryTests.cs | 2 +- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs index c1e34663a..53265af95 100644 --- a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs +++ b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs @@ -52,11 +52,20 @@ public static SentryEvent CreateExceptionEvent( var frames = UnityStackTraceParser.Parse(stackTrace, options); frames.Reverse(); - var sentryException = CreateUnityLogException(message, frames); - - return new SentryEvent(new Exception(message)) + return new SentryEvent { - SentryExceptions = [sentryException], + SentryExceptions = [new SentryException + { + Stacktrace = new SentryStackTrace { Frames = frames }, + Value = message, + Type = "LogException", + Mechanism = new Mechanism + { + Handled = false, + Type = "unity.log", + Terminal = false + } + }], Level = SentryLevel.Error }; } @@ -73,22 +82,4 @@ private static SentryThread CreateThreadFromStackTrace(List fr Stacktrace = new SentryStackTrace { Frames = frames } }; } - - private static SentryException CreateUnityLogException( - string message, - List frames, - string exceptionType = "LogError") - { - return new SentryException - { - Stacktrace = new SentryStackTrace { Frames = frames }, - Value = message, - Type = exceptionType, - Mechanism = new Mechanism - { - Handled = true, - Type = "unity.log" - } - }; - } } diff --git a/test/Sentry.Unity.Tests/IntegrationTests.cs b/test/Sentry.Unity.Tests/IntegrationTests.cs index df3f1d34e..11176be22 100644 --- a/test/Sentry.Unity.Tests/IntegrationTests.cs +++ b/test/Sentry.Unity.Tests/IntegrationTests.cs @@ -222,11 +222,11 @@ public IEnumerator DebugLogException_IsMarkedUnhandled() } [UnityTest] - public IEnumerator DebugLogError_IsCaptured() + public IEnumerator DebugLogError_OnMainThread_IsCapturedAndIsMainThreadIsTrue() { yield return SetupSceneCoroutine("1_BugFarm"); - // `Debug.LogError is getting captured as message + // 'Debug.LogError' is getting captured as message _identifyingEventValueAttribute = CreateAttribute("message", _eventMessage); using var _ = InitSentrySdk(); @@ -236,6 +236,7 @@ public IEnumerator DebugLogError_IsCaptured() var triggeredEvent = _testHttpClientHandler.GetEvent(_identifyingEventValueAttribute, _eventReceiveTimeout); Assert.That(triggeredEvent, Does.Contain(_identifyingEventValueAttribute)); + Assert.That(triggeredEvent, Does.Contain("unity.is_main_thread\":\"true\"")); } [UnityTest] @@ -248,7 +249,8 @@ public IEnumerator DebugLogError_InTask_IsCapturedAndIsMainThreadIsFalse() yield return SetupSceneCoroutine("1_BugFarm"); - var expectedAttribute = CreateAttribute("unity.is_main_thread", "false"); + // 'Debug.LogError' is getting captured as message + _identifyingEventValueAttribute = CreateAttribute("message", _eventMessage); using var _ = InitSentrySdk(); var testBehaviour = new GameObject("TestHolder").AddComponent(); @@ -257,7 +259,7 @@ public IEnumerator DebugLogError_InTask_IsCapturedAndIsMainThreadIsFalse() var triggeredEvent = _testHttpClientHandler.GetEvent(_identifyingEventValueAttribute, _eventReceiveTimeout); Assert.That(triggeredEvent, Does.Contain(_identifyingEventValueAttribute)); - Assert.That(triggeredEvent, Does.Contain(expectedAttribute)); + Assert.That(triggeredEvent, Does.Contain("unity.is_main_thread\":\"false\"")); } [UnityTest] diff --git a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs index 5e73f8751..24c3a8893 100644 --- a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs @@ -70,7 +70,7 @@ public void CreateExceptionEvent_ValidStackTrace_ExceptionHasExpectedProperties( var exception = evt.SentryExceptions!.First(); Assert.AreEqual(SampleMessage, exception.Value); - Assert.AreEqual("LogError", exception.Type); + Assert.AreEqual("LogException", exception.Type); Assert.NotNull(exception.Mechanism); Assert.True(exception.Mechanism!.Handled); Assert.AreEqual("unity.log", exception.Mechanism.Type); From 2b5a6bf62ca35b1f1ce5a7865ec83598a714188b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 12:24:40 +0100 Subject: [PATCH 10/11] Pass in handled in prep to checking --- .../UnityApplicationLoggingIntegration.cs | 2 +- .../Integrations/UnityLogEventFactory.cs | 6 ++-- .../UnityLogEventFactoryTests.cs | 30 +++++++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 3587ae94d..10d329003 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -91,7 +91,7 @@ private void ProcessException(string message, string stacktrace, LogType logType { _options.LogDebug("Exception capture has been enabled. Capturing exception through '{0}'.", nameof(UnityApplicationLoggingIntegration)); - var evt = UnityLogEventFactory.CreateExceptionEvent(message, stacktrace, _options); + var evt = UnityLogEventFactory.CreateExceptionEvent(message, stacktrace, false, _options); _hub?.CaptureEvent(evt); } } diff --git a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs index 53265af95..a450c3da7 100644 --- a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs +++ b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs @@ -42,11 +42,13 @@ public static SentryEvent CreateMessageEvent( /// /// The log message /// The Unity stacktrace string - /// Sentry Unity options + /// Whether the exception was handled or not + /// /// Sentry Unity options /// A SentryEvent with a synthetic exception public static SentryEvent CreateExceptionEvent( string message, string stackTrace, + bool handled, SentryUnityOptions options) { var frames = UnityStackTraceParser.Parse(stackTrace, options); @@ -61,7 +63,7 @@ public static SentryEvent CreateExceptionEvent( Type = "LogException", Mechanism = new Mechanism { - Handled = false, + Handled = handled, Type = "unity.log", Terminal = false } diff --git a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs index 24c3a8893..39f6bc089 100644 --- a/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs @@ -52,51 +52,63 @@ public void CreateMessageEvent_ValidStackTrace_FramesAreReversed() } [Test] - public void CreateExceptionEvent_ValidStackTrace_CreatesExceptionEvent() + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_CreatesExceptionEvent(bool handled) { var evt = UnityLogEventFactory.CreateExceptionEvent( - SampleMessage, SampleStackTrace, new SentryUnityOptions()); + SampleMessage, SampleStackTrace, handled, new SentryUnityOptions()); Assert.AreEqual(SentryLevel.Error, evt.Level); Assert.NotNull(evt.SentryExceptions); Assert.AreEqual(1, evt.SentryExceptions.Count()); + Assert.AreEqual(evt.SentryExceptions.First().Mechanism!.Handled, handled); } [Test] - public void CreateExceptionEvent_ValidStackTrace_ExceptionHasExpectedProperties() + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasExpectedProperties(bool handled) { var evt = UnityLogEventFactory.CreateExceptionEvent( - SampleMessage, SampleStackTrace, new SentryUnityOptions()); + SampleMessage, SampleStackTrace, handled, new SentryUnityOptions()); var exception = evt.SentryExceptions!.First(); Assert.AreEqual(SampleMessage, exception.Value); Assert.AreEqual("LogException", exception.Type); Assert.NotNull(exception.Mechanism); - Assert.True(exception.Mechanism!.Handled); + Assert.AreEqual(exception.Mechanism!.Handled, handled); Assert.AreEqual("unity.log", exception.Mechanism.Type); } [Test] - public void CreateExceptionEvent_ValidStackTrace_ExceptionHasStackTrace() + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasStackTrace(bool handled) { var evt = UnityLogEventFactory.CreateExceptionEvent( - SampleMessage, SampleStackTrace, new SentryUnityOptions()); + SampleMessage, SampleStackTrace, handled, new SentryUnityOptions()); var exception = evt.SentryExceptions!.First(); Assert.NotNull(exception.Stacktrace); Assert.NotNull(exception.Stacktrace!.Frames); Assert.AreEqual(2, exception.Stacktrace.Frames.Count); + Assert.AreEqual(exception.Mechanism!.Handled, handled); } [Test] - public void CreateExceptionEvent_ValidStackTrace_FramesAreReversed() + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_FramesAreReversed(bool handled) { var evt = UnityLogEventFactory.CreateExceptionEvent( - SampleMessage, SampleStackTrace, new SentryUnityOptions()); + SampleMessage, SampleStackTrace, handled, new SentryUnityOptions()); var frames = evt.SentryExceptions!.First().Stacktrace!.Frames; // After reversing, the last frame in the Unity stacktrace should be first Assert.AreEqual("BugFarmButtons:LogError ()", frames[0].Function); Assert.AreEqual("UnityEngine.DebugLogHandler:LogFormat (UnityEngine.LogType,UnityEngine.Object,string,object[])", frames[1].Function); + var exception = evt.SentryExceptions!.First(); + Assert.AreEqual(exception.Mechanism!.Handled, handled); } } From 27baffbe93d158e72c331252283997a15958028f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 12:43:04 +0100 Subject: [PATCH 11/11] Synthetic --- src/Sentry.Unity/Il2CppEventProcessor.cs | 6 ++++++ src/Sentry.Unity/Integrations/UnityLogEventFactory.cs | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Sentry.Unity/Il2CppEventProcessor.cs b/src/Sentry.Unity/Il2CppEventProcessor.cs index 8802a5f03..e7c4b83d5 100644 --- a/src/Sentry.Unity/Il2CppEventProcessor.cs +++ b/src/Sentry.Unity/Il2CppEventProcessor.cs @@ -47,6 +47,12 @@ public void Process(Exception incomingException, SentryEvent sentryEvent) // In case they don't we update the offsets to match the GameAssembly library. foreach (var (sentryException, exception) in sentryExceptions.Zip(exceptions, (se, ex) => (se, ex))) { + if (sentryException.Mechanism?.Synthetic is true) + { + // Skip synthetic exceptions since they have no native counterpart + continue; + } + var sentryStacktrace = sentryException.Stacktrace; if (sentryStacktrace == null) { diff --git a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs index a450c3da7..135f65a19 100644 --- a/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs +++ b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs @@ -65,7 +65,8 @@ public static SentryEvent CreateExceptionEvent( { Handled = handled, Type = "unity.log", - Terminal = false + Terminal = false, + Synthetic = true } }], Level = SentryLevel.Error