From 1c2ed951cd237c4b25666b09492c59f2198cf77b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 21 Oct 2025 13:14:10 +0200 Subject: [PATCH 01/20] Bump sentry-dotnet to version 6 - Update submodule to version 6 - Rename BreadcrumbLevel.Critical to BreadcrumbLevel.Fatal - Remove obsolete CaptureUserFeedback methods --- .../BreadcrumbExtensions.cs | 2 +- src/Sentry.Unity.iOS/NativeScopeObserver.cs | 2 +- src/Sentry.Unity/SentrySdk.Dotnet.cs | 21 ------------------- src/sentry-dotnet | 2 +- test/Sentry.Unity.Tests/Stubs/TestHub.cs | 8 ++----- .../NativeScopeObserverTests.cs | 2 +- 6 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/Sentry.Unity.Android/BreadcrumbExtensions.cs b/src/Sentry.Unity.Android/BreadcrumbExtensions.cs index c4d0ba69b..d828755e9 100644 --- a/src/Sentry.Unity.Android/BreadcrumbExtensions.cs +++ b/src/Sentry.Unity.Android/BreadcrumbExtensions.cs @@ -17,7 +17,7 @@ public static AndroidJavaObject ToJavaSentryLevel(this BreadcrumbLevel level) using var javaSentryLevel = new AndroidJavaClass("io.sentry.SentryLevel"); return level switch { - BreadcrumbLevel.Critical => javaSentryLevel.GetStatic("FATAL"), + BreadcrumbLevel.Fatal => javaSentryLevel.GetStatic("FATAL"), BreadcrumbLevel.Error => javaSentryLevel.GetStatic("ERROR"), BreadcrumbLevel.Warning => javaSentryLevel.GetStatic("WARNING"), BreadcrumbLevel.Debug => javaSentryLevel.GetStatic("DEBUG"), diff --git a/src/Sentry.Unity.iOS/NativeScopeObserver.cs b/src/Sentry.Unity.iOS/NativeScopeObserver.cs index f5270c6bd..8c8637eb4 100644 --- a/src/Sentry.Unity.iOS/NativeScopeObserver.cs +++ b/src/Sentry.Unity.iOS/NativeScopeObserver.cs @@ -42,7 +42,7 @@ internal static int GetBreadcrumbLevel(BreadcrumbLevel breadcrumbLevel) => BreadcrumbLevel.Info => 2, BreadcrumbLevel.Warning => 3, BreadcrumbLevel.Error => 4, - BreadcrumbLevel.Critical => 5, + BreadcrumbLevel.Fatal => 5, _ => 0 }; } diff --git a/src/Sentry.Unity/SentrySdk.Dotnet.cs b/src/Sentry.Unity/SentrySdk.Dotnet.cs index f9548d83e..b4ea8fb07 100644 --- a/src/Sentry.Unity/SentrySdk.Dotnet.cs +++ b/src/Sentry.Unity/SentrySdk.Dotnet.cs @@ -324,27 +324,6 @@ public static void CaptureFeedback(string message, string? contactEmail = null, => Sentry.SentrySdk.CaptureFeedback(new SentryFeedback(message, contactEmail, name, replayId, url, associatedEventId), scope, hint); - /// - /// Captures a user feedback. - /// - /// The user feedback to send to Sentry. - [DebuggerStepThrough] - [Obsolete("Use CaptureFeedback instead.")] - public static void CaptureUserFeedback(UserFeedback userFeedback) - => Sentry.SentrySdk.CaptureUserFeedback(userFeedback); - - /// - /// Captures a user feedback. - /// - /// The event Id. - /// The user email. - /// The user comments. - /// The optional username. - [DebuggerStepThrough] - [Obsolete("Use CaptureFeedback instead.")] - public static void CaptureUserFeedback(SentryId eventId, string email, string comments, string? name = null) - => Sentry.SentrySdk.CaptureUserFeedback(new UserFeedback(eventId, name, email, comments)); - /// /// Captures a transaction. /// diff --git a/src/sentry-dotnet b/src/sentry-dotnet index 0a552fcb1..080493bf5 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit 0a552fcb1b6de8dfa9145cd682dc6a23a97a1309 +Subproject commit 080493bf59dc5fbf49bcae0a4d203bce0c86447a diff --git a/test/Sentry.Unity.Tests/Stubs/TestHub.cs b/test/Sentry.Unity.Tests/Stubs/TestHub.cs index c10b96bb4..9d258ecc5 100644 --- a/test/Sentry.Unity.Tests/Stubs/TestHub.cs +++ b/test/Sentry.Unity.Tests/Stubs/TestHub.cs @@ -36,12 +36,6 @@ public void CaptureFeedback(SentryFeedback feedback, Scope? scope = null, Sentry throw new NotImplementedException(); } - [Obsolete("Obsolete")] - public void CaptureUserFeedback(UserFeedback userFeedback) - { - throw new NotImplementedException(); - } - public void CaptureTransaction(SentryTransaction transaction) => _capturedTransactions.Add(transaction); @@ -161,6 +155,8 @@ public TransactionContext ContinueTrace(SentryTraceHeader? traceHeader, BaggageH throw new NotImplementedException(); } + public bool IsSessionActive => false; + public void StartSession() { // TODO: test sessions diff --git a/test/Sentry.Unity.iOS.Tests/NativeScopeObserverTests.cs b/test/Sentry.Unity.iOS.Tests/NativeScopeObserverTests.cs index 0323e6173..3cabbb6d0 100644 --- a/test/Sentry.Unity.iOS.Tests/NativeScopeObserverTests.cs +++ b/test/Sentry.Unity.iOS.Tests/NativeScopeObserverTests.cs @@ -22,7 +22,7 @@ public void GetTimestamp_ReturnStringConformsToISO8601() [TestCase(BreadcrumbLevel.Info, 2)] [TestCase(BreadcrumbLevel.Warning, 3)] [TestCase(BreadcrumbLevel.Error, 4)] - [TestCase(BreadcrumbLevel.Critical, 5)] + [TestCase(BreadcrumbLevel.Fatal, 5)] public void GetBreadcrumbLevel_TestCases(BreadcrumbLevel level, int expectedNativeLevel) { var actualLevel = NativeScopeObserver.GetBreadcrumbLevel(level); From 5c2eeedad93d6a0a8d08bfd392a2c1105ec9eab8 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 24 Oct 2025 14:43:31 +0200 Subject: [PATCH 02/20] Bump --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index 080493bf5..a267d32d6 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit 080493bf59dc5fbf49bcae0a4d203bce0c86447a +Subproject commit a267d32d641a66087e902f2aeeb23af9a4e32e40 From bb5164509d72e3651737b32a566ecabfd9b41d66 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 24 Oct 2025 14:51:40 +0200 Subject: [PATCH 03/20] Bump global.json as well --- global.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index 9cd89532c..f260fed21 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "9.0.304", - "workloadVersion": "9.0.304", + "version": "10.0.100-rc.2.25502.107", + "workloadVersion": "10.0.100-rc.2.25513.4", "rollForward": "disable", "allowPrerelease": false } From c2a36c0d4dc822106d91bb0fd48e530abf9e0311 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 24 Oct 2025 15:03:32 +0200 Subject: [PATCH 04/20] Bump --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index a267d32d6..382fa2eca 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit a267d32d641a66087e902f2aeeb23af9a4e32e40 +Subproject commit 382fa2ecad6e78ccf6df81c38d6379860d91e933 From c0d6f3dcef1e93d4e82cdda531a5cac02d8f35ad Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 24 Oct 2025 15:16:43 +0200 Subject: [PATCH 05/20] TestHub update --- test/Sentry.Unity.Tests/Stubs/TestHub.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Sentry.Unity.Tests/Stubs/TestHub.cs b/test/Sentry.Unity.Tests/Stubs/TestHub.cs index 9d258ecc5..c64f402a8 100644 --- a/test/Sentry.Unity.Tests/Stubs/TestHub.cs +++ b/test/Sentry.Unity.Tests/Stubs/TestHub.cs @@ -143,6 +143,11 @@ public void BindException(Exception exception, ISpan span) throw new NotImplementedException(); } + public W3CTraceparentHeader? GetTraceparentHeader() + { + throw new NotImplementedException(); + } + public TransactionContext ContinueTrace(string? traceHeader, string? baggageHeader, string? name = null, string? operation = null) { From c4ab1994ffd4f5ed6927370c9c74675a82a3614b Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 28 Oct 2025 13:06:38 +0100 Subject: [PATCH 06/20] Fixed breadcrumb level in smoketester --- test/Scripts.Integration.Test/Scripts/SmokeTester.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Scripts.Integration.Test/Scripts/SmokeTester.cs b/test/Scripts.Integration.Test/Scripts/SmokeTester.cs index 1cbf56009..8e7f76a16 100644 --- a/test/Scripts.Integration.Test/Scripts/SmokeTester.cs +++ b/test/Scripts.Integration.Test/Scripts/SmokeTester.cs @@ -183,7 +183,7 @@ private IEnumerator SmokeTestCoroutine() currentMessage++; // The exception event t.ExpectMessage(currentMessage, "'type':'event'"); - t.ExpectMessage(currentMessage, "'message':'crumb','type':'error','data':{'foo':'bar'},'category':'bread','level':'critical'}"); + t.ExpectMessage(currentMessage, "'message':'crumb','type':'error','data':{'foo':'bar'},'category':'bread','level':'fatal'}"); t.ExpectMessage(currentMessage, "'message':'scope-crumb'}"); t.ExpectMessage(currentMessage, "'extra':{'extra-key':42}"); t.ExpectMessage(currentMessage, "'tag-key':'tag-value'"); @@ -260,7 +260,7 @@ public static void HasCrashedTest() private static void AddContext() { - SentrySdk.AddBreadcrumb("crumb", "bread", "error", new Dictionary() { { "foo", "bar" } }, BreadcrumbLevel.Critical); + SentrySdk.AddBreadcrumb("crumb", "bread", "error", new Dictionary() { { "foo", "bar" } }, BreadcrumbLevel.Fatal); SentrySdk.ConfigureScope((Scope scope) => { scope.SetExtra("extra-key", 42); From 4ce9c42f6304b5fdeb2c4c1914f13fd8d708c0d7 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 28 Oct 2025 13:38:23 +0100 Subject: [PATCH 07/20] Fix WebGL smoke tester too --- scripts/smoke-test-webgl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-test-webgl.py b/scripts/smoke-test-webgl.py index 90acc0df3..c144983aa 100644 --- a/scripts/smoke-test-webgl.py +++ b/scripts/smoke-test-webgl.py @@ -203,7 +203,7 @@ def waitUntil(condition, interval=0.1, timeout=1): currentMessage += 1 t.ExpectMessage(currentMessage, "'type':'event'") t.ExpectMessage( - currentMessage, "'message':'crumb','type':'error','data':{'foo':'bar'},'category':'bread','level':'critical'}") + currentMessage, "'message':'crumb','type':'error','data':{'foo':'bar'},'category':'bread','level':'fatal'}") t.ExpectMessage(currentMessage, "'message':'scope-crumb'}") t.ExpectMessage(currentMessage, "'extra':{'extra-key':42}") t.ExpectMessage(currentMessage, "'tag-key':'tag-value'") From e39fa1a37c4168d7cbfc0c44d71b24647b70658d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 29 Oct 2025 15:53:09 +0100 Subject: [PATCH 08/20] another day, another bump --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index 382fa2eca..85615664d 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit 382fa2ecad6e78ccf6df81c38d6379860d91e933 +Subproject commit 85615664d4060ad475e316911bd79d364b50567a From 742f67f38b946304a9a2e64e630361afbda30b0d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 29 Oct 2025 17:02:25 +0100 Subject: [PATCH 09/20] Bu-bu-bump --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index 85615664d..cde2961c3 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit 85615664d4060ad475e316911bd79d364b50567a +Subproject commit cde2961c352f8400a95aeb4679c431f9c6c58cca From 2ccdfa9059f10835027425be4fe6c203f17f8291 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Thu, 30 Oct 2025 15:06:10 +0100 Subject: [PATCH 10/20] feat: Structured Logging (#2368) --- CHANGELOG.md | 6 +- .../Resources/Sentry/SentryOptions.asset | 10 +- .../Assets/Scripts/BugFarmButtons.cs | 2 +- .../Assets/Scripts/ThreadingSamples.cs | 2 +- .../ConfigurationWindow/LoggingTab.cs | 136 ++++++++++++------ .../UnityApplicationLoggingIntegration.cs | 124 +++++++++------- .../Integrations/UnityErrorLogException.cs | 1 + .../UnityLogHandlerIntegration.cs | 96 ++++++++++--- .../ScriptableSentryUnityOptions.cs | 22 +++ src/Sentry.Unity/SentrySdk.Dotnet.cs | 11 ++ src/Sentry.Unity/SentryUnityOptions.cs | 59 +++++++- .../Stubs/TestStructuredLogger.cs | 24 ++++ ...UnityApplicationLoggingIntegrationTests.cs | 73 ++++++++++ .../UnityLogHandlerIntegrationTests.cs | 108 +++++++++++++- 14 files changed, 554 insertions(+), 120 deletions(-) create mode 100644 test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 047904e22..a89e9fdcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog -## Unreleased +## Unreleased ### Breaking Changes - `sentry-native` is now built on Ubuntu 22.04 instead of Ubuntu 20.04, which reached EOL in May 2025. If you are running you game on a server on Ubuntu 20.04, you should update the OS before upgrading to this SDK version. ([#2355](https://github.com/getsentry/sentry-unity/pull/2355)) +### Features + +- 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)) + ### Dependencies - Bump CLI from v2.56.0 to v2.56.1 ([#2356](https://github.com/getsentry/sentry-unity/pull/2356)) diff --git a/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset b/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset index 542d04e04..069914f26 100644 --- a/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset +++ b/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset @@ -35,11 +35,17 @@ MonoBehaviour: k__BackingField: 100 k__BackingField: 20 k__BackingField: 10 + k__BackingField: 1 + k__BackingField: 0 + k__BackingField: 1 + k__BackingField: 1 + k__BackingField: 1 + k__BackingField: 1 + k__BackingField: 0 k__BackingField: 1 k__BackingField: 1 k__BackingField: 1 k__BackingField: 1 - k__BackingField: 1 k__BackingField: 1 k__BackingField: 100 k__BackingField: 1 @@ -51,7 +57,7 @@ MonoBehaviour: k__BackingField: 1 k__BackingField: 2000 k__BackingField: 30 - k__BackingField: 1 + k__BackingField: 0 k__BackingField: 5000 k__BackingField: 1 k__BackingField: f401000057020000 diff --git a/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs b/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs index 9cc7a814b..e0a3d072b 100644 --- a/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs +++ b/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs @@ -71,7 +71,7 @@ public void LogError() // Error logs get captured as messages and do not have a stacktrace attached by default. This is an opt-in feature. // Note: That stack traces generated for message events are provided without line numbers. See known limitations // https://docs.sentry.io/platforms/unity/troubleshooting/known-limitations/#line-numbers-missing-in-events-captured-through-debuglogerror-or-sentrysdkcapturemessage - Debug.LogError("Debug.LogError() called"); + Debug.LogError("This is a 'Debug.LogError()' message."); } } diff --git a/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs b/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs index 098446ffa..54575a7ea 100644 --- a/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs +++ b/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs @@ -81,7 +81,7 @@ public void LogError() // Error logs get captured as messages and do not have a stacktrace attached by default. This is an opt-in feature. // Note: That stack traces generated for message events are provided without line numbers. See known limitations // https://docs.sentry.io/platforms/unity/troubleshooting/known-limitations/#line-numbers-missing-in-events-captured-through-debuglogerror-or-sentrysdkcapturemessage - Debug.LogError("Debug.LogError() called"); + Debug.LogError("This is a 'Debug.LogError()' message."); } }); } diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs index da13dd041..4a33285f3 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs @@ -9,31 +9,35 @@ internal static class LoggingTab internal static void Display(ScriptableSentryUnityOptions options) { { - options.EnableLogDebouncing = EditorGUILayout.BeginToggleGroup( - new GUIContent("Enable Log Debouncing", "The SDK debounces log messages of the " + - "same type if they are more frequent than once per second."), - options.EnableLogDebouncing); + GUILayout.Label("Structured Logging - Experimental", EditorStyles.boldLabel); + + options.EnableStructuredLogging = EditorGUILayout.BeginToggleGroup( + new GUIContent("Send Logs for:", "Enables the SDK to forward log messages to Sentry " + + "based on the log level."), + options.EnableStructuredLogging); EditorGUI.indentLevel++; - options.DebounceTimeLog = EditorGUILayout.IntField( - new GUIContent("Log Debounce [ms]", "The time that has to pass between events of " + - "LogType.Log before the SDK sends it again."), - options.DebounceTimeLog); - options.DebounceTimeLog = Math.Max(0, options.DebounceTimeLog); - - options.DebounceTimeWarning = EditorGUILayout.IntField( - new GUIContent("Warning Debounce [ms]", "The time that has to pass between events of " + - "LogType.Warning before the SDK sends it again."), - options.DebounceTimeWarning); - options.DebounceTimeWarning = Math.Max(0, options.DebounceTimeWarning); - - options.DebounceTimeError = EditorGUILayout.IntField( - new GUIContent("Error Debounce [ms]", "The time that has to pass between events of " + - "LogType.Assert, LogType.Exception and LogType.Error before " + - "the SDK sends it again."), - options.DebounceTimeError); - options.DebounceTimeError = Math.Max(0, options.DebounceTimeError); + options.StructuredLogOnDebugLog = EditorGUILayout.Toggle( + new GUIContent("Debug.Log", + "Whether the SDK should forward Debug.Log messages to Sentry structured logging"), + options.StructuredLogOnDebugLog); + options.StructuredLogOnDebugLogWarning = EditorGUILayout.Toggle( + new GUIContent("Debug.LogWarning", + "Whether the SDK should forward Debug.LogWarning messages to Sentry structured logging"), + options.StructuredLogOnDebugLogWarning); + options.StructuredLogOnDebugLogAssertion = EditorGUILayout.Toggle( + new GUIContent("Debug.LogAssertion", + "Whether the SDK should forward Debug.LogAssertion messages to Sentry structured logging"), + options.StructuredLogOnDebugLogAssertion); + options.StructuredLogOnDebugLogError = EditorGUILayout.Toggle( + new GUIContent("Debug.LogError", + "Whether the SDK should forward Debug.LogError messages to Sentry structured logging"), + options.StructuredLogOnDebugLogError); + options.StructuredLogOnDebugLogException = EditorGUILayout.Toggle( + new GUIContent("Debug.LogException", + "Whether the SDK should forward Debug.LogException messages to Sentry structured logging"), + options.StructuredLogOnDebugLogException); EditorGUI.indentLevel--; EditorGUILayout.EndToggleGroup(); @@ -44,42 +48,45 @@ internal static void Display(ScriptableSentryUnityOptions options) EditorGUILayout.Space(); { - GUILayout.Label("Automatically capture and send events for:", EditorStyles.boldLabel); - EditorGUI.indentLevel++; + GUILayout.Label("Breadcrumbs", EditorStyles.boldLabel); - options.CaptureLogErrorEvents = EditorGUILayout.Toggle( - new GUIContent("Debug.LogError", "Whether the SDK automatically captures events for 'Debug.LogError'."), - options.CaptureLogErrorEvents); + if (options.EnableStructuredLogging) + { + options.AttachBreadcrumbsToEvents = EditorGUILayout.BeginToggleGroup( + new GUIContent("Attach logs as breadcrumbs in addition to sending them as structured logs", "Whether the SDK should attach breadcrumbs to events in addition to structured logging."), + options.AttachBreadcrumbsToEvents); - EditorGUILayout.Space(); - GUILayout.Label("Automatically add breadcrumbs for:", EditorStyles.boldLabel); + GUILayout.Label("Note: With sending structured logs enabled you have to opt-into adding breadcrumbs to events.", EditorStyles.boldLabel); + } + + EditorGUI.indentLevel++; options.BreadcrumbsForLogs = EditorGUILayout.Toggle( new GUIContent("Debug.Log", "Whether the SDK automatically adds breadcrumbs 'Debug.Log'."), options.BreadcrumbsForLogs); options.BreadcrumbsForWarnings = EditorGUILayout.Toggle( - new GUIContent("Debug.Warning", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogWarning'."), + new GUIContent("Debug.LogWarning", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogWarning'."), options.BreadcrumbsForWarnings); options.BreadcrumbsForAsserts = EditorGUILayout.Toggle( - new GUIContent("Debug.Assert", "Whether the SDK automatically adds breadcrumbs for 'Debug.Assert'."), + new GUIContent("Debug.LogAssertion", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogAssertion'."), options.BreadcrumbsForAsserts); options.BreadcrumbsForErrors = EditorGUILayout.Toggle( - new GUIContent("Debug.Error", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogError'."), + new GUIContent("Debug.LogError", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogError'."), options.BreadcrumbsForErrors); - EditorGUI.indentLevel--; - } - - EditorGUILayout.Space(); - EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray); - EditorGUILayout.Space(); + EditorGUILayout.Space(); - { options.MaxBreadcrumbs = EditorGUILayout.IntField( new GUIContent("Max Breadcrumbs", "Maximum number of breadcrumbs that get captured." + "\nDefault: 100"), options.MaxBreadcrumbs); options.MaxBreadcrumbs = Math.Max(0, options.MaxBreadcrumbs); + + EditorGUI.indentLevel--; + if (options.EnableStructuredLogging) + { + EditorGUILayout.EndToggleGroup(); + } } EditorGUILayout.Space(); @@ -87,22 +94,63 @@ internal static void Display(ScriptableSentryUnityOptions options) EditorGUILayout.Space(); { - GUILayout.Label("Attach the stack trace when capturing log messages. NOTE: These will not contain line numbers.", EditorStyles.boldLabel); + GUILayout.Label("CaptureMessage Settings", EditorStyles.boldLabel); EditorGUI.indentLevel++; + options.CaptureLogErrorEvents = EditorGUILayout.Toggle( + new GUIContent("Capture LogError", "Whether the SDK automatically captures events for 'Debug.LogError'."), + options.CaptureLogErrorEvents); + options.AttachStacktrace = EditorGUILayout.Toggle( - new GUIContent("Attach Stack Trace", "Whether to include a stack trace for non " + - "error events like logs. Even when Unity didn't include and no " + - "exception was thrown. Refer to AttachStacktrace on sentry docs."), + new GUIContent("Attach Stack Trace", "Whether the SDK should include a stack trace for CaptureMessage " + + "events. Refer to AttachStacktrace on sentry docs."), options.AttachStacktrace); - EditorGUI.indentLevel--; + GUILayout.Label("Note: The stack trace quality will depend on the IL2CPP line number setting and might not contain line numbers.", EditorStyles.boldLabel); + // Enhanced not supported on IL2CPP so not displaying this for the time being: // Options.StackTraceMode = (StackTraceMode) EditorGUILayout.EnumPopup( // new GUIContent("Stacktrace Mode", "Enhanced is the default." + // "\n - Enhanced: Include async, return type, args,..." + // "\n - Original - Default .NET stack trace format."), // Options.StackTraceMode); + + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(); + EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray); + EditorGUILayout.Space(); + + { + options.EnableLogDebouncing = EditorGUILayout.BeginToggleGroup( + new GUIContent("Enable Log Debouncing", "The SDK debounces log messages of the " + + "same type if they are more frequent than once per second."), + options.EnableLogDebouncing); + + EditorGUI.indentLevel++; + + options.DebounceTimeLog = EditorGUILayout.IntField( + new GUIContent("Log Debounce [ms]", "The time that has to pass between events of " + + "LogType.Log before the SDK sends it again."), + options.DebounceTimeLog); + options.DebounceTimeLog = Math.Max(0, options.DebounceTimeLog); + + options.DebounceTimeWarning = EditorGUILayout.IntField( + new GUIContent("Warning Debounce [ms]", "The time that has to pass between events of " + + "LogType.Warning before the SDK sends it again."), + options.DebounceTimeWarning); + options.DebounceTimeWarning = Math.Max(0, options.DebounceTimeWarning); + + options.DebounceTimeError = EditorGUILayout.IntField( + new GUIContent("Error Debounce [ms]", "The time that has to pass between events of " + + "LogType.Assert, LogType.Exception and LogType.Error before " + + "the SDK sends it again."), + options.DebounceTimeError); + options.DebounceTimeError = Math.Max(0, options.DebounceTimeError); + + EditorGUI.indentLevel--; + EditorGUILayout.EndToggleGroup(); } } } diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 52143fe65..7c222aaf9 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -1,3 +1,5 @@ +using System; +using Sentry.Extensibility; using Sentry.Integrations; using UnityEngine; @@ -12,12 +14,13 @@ internal class UnityApplicationLoggingIntegration : ISdkIntegration { private readonly IApplication _application; private readonly bool _captureExceptions; - private ErrorTimeDebounce? _errorTimeDebounce; - private LogTimeDebounce? _logTimeDebounce; - private WarningTimeDebounce? _warningTimeDebounce; + + private ErrorTimeDebounce _errorTimeDebounce = null!; // Set in Register + private LogTimeDebounce _logTimeDebounce = null!; // Set in Register + private WarningTimeDebounce _warningTimeDebounce = null!; // Set in Register private IHub? _hub; - private SentryUnityOptions? _options; + private SentryUnityOptions _options = null!; // Set in Register internal UnityApplicationLoggingIntegration(bool captureExceptions = false, IApplication? application = null) { @@ -28,11 +31,8 @@ internal UnityApplicationLoggingIntegration(bool captureExceptions = false, IApp public void Register(IHub hub, SentryOptions sentryOptions) { _hub = hub; - _options = sentryOptions as SentryUnityOptions; - if (_options is null) - { - return; - } + // This should never throw + _options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'."); _logTimeDebounce = new LogTimeDebounce(_options.DebounceTimeLog); _warningTimeDebounce = new WarningTimeDebounce(_options.DebounceTimeWarning); @@ -49,72 +49,100 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo return; } - // We're not capturing or creating breadcrumbs from SDK logs + // We're not capturing the SDK's own logs if (message.StartsWith(UnityLogger.LogTag)) { return; } - // LogType.Exception are getting handled by the UnityLogHandlerIntegration - // Unless we're configured to handle them - i.e. WebGL - if (logType is LogType.Exception && !_captureExceptions) + if (IsGettingDebounced(logType)) { + _options.LogDebug("Log message of type '{0}' is getting debounced.", logType); return; } - if (_options?.EnableLogDebouncing is true) + ProcessException(message, stacktrace, logType); + ProcessError(message, stacktrace, logType); + ProcessBreadcrumbs(message, logType); + } + + private bool IsGettingDebounced(LogType logType) + { + if (_options.EnableLogDebouncing is false) { - var debounced = logType switch - { - LogType.Exception => _errorTimeDebounce?.Debounced(), - LogType.Error or LogType.Assert => _errorTimeDebounce?.Debounced(), - LogType.Log => _logTimeDebounce?.Debounced(), - LogType.Warning => _warningTimeDebounce?.Debounced(), - _ => true - }; - - if (debounced is not true) - { - return; - } + return false; } - if (logType is LogType.Exception) + return logType switch + { + LogType.Exception => !_errorTimeDebounce.Debounced(), + LogType.Error or LogType.Assert => !_errorTimeDebounce.Debounced(), + LogType.Log => !_logTimeDebounce.Debounced(), + LogType.Warning => !_warningTimeDebounce.Debounced(), + _ => true + }; + } + + 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 + 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); + _hub?.CaptureException(ule); + } + } - // We don't capture breadcrumbs for exceptions - the .NET SDK handles this + private void ProcessError(string message, string stacktrace, LogType logType) + { + if (logType is not LogType.Error || !_options.CaptureLogErrorEvents) + { return; } - if (logType is LogType.Error && _options?.CaptureLogErrorEvents is true) + _options.LogDebug("Error capture for 'Debug.LogError' is enabled. Capturing message."); + + if (_options.AttachStacktrace && !string.IsNullOrEmpty(stacktrace)) { - if (_options?.AttachStacktrace is true && !string.IsNullOrEmpty(stacktrace)) - { - var ule = new UnityErrorLogException(message, stacktrace, _options); - var sentryEvent = new SentryEvent(ule) { Level = SentryLevel.Error }; - - _hub.CaptureEvent(sentryEvent); - } - else - { - _hub.CaptureMessage(message, level: SentryLevel.Error); - } - } + _options.LogDebug("Attaching stacktrace to event."); - // Capture so the next event includes this error as breadcrumb - if (_options?.AddBreadcrumbsForLogType[logType] is true) + var ule = new UnityErrorLogException(message, stacktrace, _options); + var sentryEvent = new SentryEvent(ule) { Level = SentryLevel.Error }; + + _hub?.CaptureEvent(sentryEvent); + } + else { - _hub.AddBreadcrumb(message: message, category: "unity.logger", level: ToBreadcrumbLevel(logType)); + _hub?.CaptureMessage(message, level: SentryLevel.Error); } } - private void OnQuitting() + private void ProcessBreadcrumbs(string message, LogType logType) { - _application.LogMessageReceived -= OnLogMessageReceived; + if (logType is LogType.Exception) + { + // Capturing of breadcrumbs for exceptions happens inside the .NET SDK + return; + } + + // Breadcrumb collection on top of structure log capture must be opted in + if (_options.Experimental is { EnableLogs: true, AttachBreadcrumbsToEvents: false }) + { + return; + } + + if (_options.AddBreadcrumbsForLogType.TryGetValue(logType, out var value) && value) + { + _options.LogDebug("Adding breadcrumb for log message of type: {0}", logType); + _hub?.AddBreadcrumb(message: message, category: "unity.logger", level: ToBreadcrumbLevel(logType)); + } } + private void OnQuitting() => _application.LogMessageReceived -= OnLogMessageReceived; + private static BreadcrumbLevel ToBreadcrumbLevel(LogType logType) => logType switch { diff --git a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs index 8d7a6daeb..c1dcf6237 100644 --- a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs +++ b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs @@ -24,6 +24,7 @@ internal class UnityErrorLogException : Exception private readonly IDiagnosticLogger? _logger; public UnityErrorLogException(string logString, string logStackTrace, SentryOptions? options) + : base(logString) { _logString = logString; _logStackTrace = logStackTrace; diff --git a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs index b93b15a1a..d869a4ceb 100644 --- a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs @@ -13,31 +13,36 @@ namespace Sentry.Unity.Integrations; internal sealed class UnityLogHandlerIntegration : ISdkIntegration, ILogHandler { private readonly IApplication _application; - + private readonly Func? _loggerFactory; private IHub? _hub; - private SentryUnityOptions? _sentryOptions; - + private SentryUnityOptions _options = null!; // Set during register private ILogHandler _unityLogHandler = null!; // Set during register + private SentryStructuredLogger _structuredLogger = null!; // Set during register - public UnityLogHandlerIntegration(SentryUnityOptions options, IApplication? application = null) + public UnityLogHandlerIntegration(IApplication? application = null) { _application = application ?? ApplicationAdapter.Instance; } + // For testing: allows injecting a custom logger factory + internal UnityLogHandlerIntegration(IApplication? application, Func loggerFactory) + : this(application) + { + _loggerFactory = loggerFactory; + } + public void Register(IHub hub, SentryOptions sentryOptions) { _hub = hub; - _sentryOptions = sentryOptions as SentryUnityOptions; - if (_sentryOptions is null) - { - return; - } + // This should never happen, but if it does... + _options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'."); + _structuredLogger = _loggerFactory?.Invoke() ?? _hub.Logger; // If called twice (i.e. init with the same options object) the integration will reference itself as the // original handler loghandler and endlessly forward to itself if (Debug.unityLogger.logHandler == this) { - _sentryOptions.DiagnosticLogger?.LogWarning("UnityLogHandlerIntegration has already been registered."); + _options.DiagnosticLogger?.LogWarning("UnityLogHandlerIntegration has already been registered."); return; } @@ -51,7 +56,7 @@ public void LogException(Exception exception, UnityEngine.Object context) { try { - CaptureException(exception, context); + ProcessException(exception, context); } finally { @@ -60,7 +65,7 @@ public void LogException(Exception exception, UnityEngine.Object context) } } - internal void CaptureException(Exception exception, UnityEngine.Object? context) + internal void ProcessException(Exception exception, UnityEngine.Object? context) { if (_hub?.IsEnabled is not true) { @@ -75,19 +80,72 @@ internal void CaptureException(Exception exception, UnityEngine.Object? context) exception.Data[Mechanism.HandledKey] = false; exception.Data[Mechanism.MechanismKey] = "Unity.LogException"; _ = _hub.CaptureException(exception); + + if (_options.Experimental.CaptureStructuredLogsForLogType.TryGetValue(LogType.Exception, out var captureException) && captureException) + { + _options.LogDebug("Capturing structured log message of type '{0}'.", LogType.Exception); + _structuredLogger.LogError(exception.Message); + } } public void LogFormat(LogType logType, UnityEngine.Object? context, string format, params object[] args) { - // Always pass the log back to Unity - // Capturing of `Debug`, `Warning`, and `Error` happens in the Application Logging Integration. - // The LogHandler does not have access to the stacktrace information required - _unityLogHandler.LogFormat(logType, context, format, args); + try + { + ProcessLog(logType, context, format, args); + } + finally + { + // Always pass the log back to Unity + // Capturing of `Debug`, `Warning`, and `Error` happens in the Application Logging Integration. + // The LogHandler does not have access to the stacktrace information required + _unityLogHandler.LogFormat(logType, context, format, args); + } + } + + private void ProcessLog(LogType logType, UnityEngine.Object? context, string format, params object[] args) + { + if (_hub?.IsEnabled is not true || !_options.Experimental.EnableLogs) + { + return; + } + + // We're not capturing the SDK's own logs. + if (args.Length > 1 && Equals(args[0], UnityLogger.LogTag)) + { + return; + } + + ProcessStructuredLog(logType, format, args); + } + + private void ProcessStructuredLog(LogType logType, string format, params object[] args) + { + if (!_options.Experimental.CaptureStructuredLogsForLogType.TryGetValue(logType, out var captureLog) || !captureLog) + { + return; + } + + _options.LogDebug("Capturing structured log message of type '{0}'.", logType); + + switch (logType) + { + case LogType.Log: + _structuredLogger.LogInfo(format, args); + break; + case LogType.Warning: + _structuredLogger.LogWarning(format, args); + break; + case LogType.Assert: + case LogType.Error: + _structuredLogger.LogError(format, args); + break; + } } private void OnQuitting() { - _sentryOptions?.DiagnosticLogger?.LogInfo("OnQuitting was invoked. Unhooking log callback and pausing session."); + _options.DiagnosticLogger?.LogInfo("OnQuitting was invoked. Unhooking log callback and pausing session."); // Note: iOS applications are usually suspended and do not quit. You should tick "Exit on Suspend" in Player settings for iOS builds to cause the game to quit and not suspend, otherwise you may not see this call. // If "Exit on Suspend" is not ticked then you will see calls to OnApplicationPause instead. @@ -97,10 +155,10 @@ private void OnQuitting() // 'OnQuitting' is invoked even when an uncaught exception happens in the ART. To make sure the .NET // SDK checks with the native layer on restart if the previous run crashed (through the CrashedLastRun callback) // we'll just pause sessions on shutdown. On restart they can be closed with the right timestamp and as 'exited'. - if (_sentryOptions?.AutoSessionTracking is true) + if (_options.AutoSessionTracking) { _hub?.PauseSession(); } - _hub?.FlushAsync(_sentryOptions?.ShutdownTimeout ?? TimeSpan.FromSeconds(1)).GetAwaiter().GetResult(); + _hub?.FlushAsync(_options.ShutdownTimeout).GetAwaiter().GetResult(); } } diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index 90e5808fb..7f1a60f26 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -58,6 +58,15 @@ public static string GetConfigPath(string? notDefaultConfigName = null) [field: SerializeField] public int MaxViewHierarchyObjectChildCount { get; set; } = 20; [field: SerializeField] public int MaxViewHierarchyDepth { get; set; } = 10; + [field: SerializeField] public bool EnableStructuredLogging { get; set; } = false; + [field: SerializeField] public bool StructuredLogOnDebugLog { get; set; } = false; + [field: SerializeField] public bool StructuredLogOnDebugLogWarning { get; set; } = true; + [field: SerializeField] public bool StructuredLogOnDebugLogAssertion { get; set; } = true; + [field: SerializeField] public bool StructuredLogOnDebugLogError { get; set; } = true; + [field: SerializeField] public bool StructuredLogOnDebugLogException { get; set; } = true; + + [field: SerializeField] public bool AttachBreadcrumbsToEvents { get; set; } = false; + [field: SerializeField] public bool BreadcrumbsForLogs { get; set; } = true; [field: SerializeField] public bool BreadcrumbsForWarnings { get; set; } = true; [field: SerializeField] public bool BreadcrumbsForAsserts { get; set; } = true; @@ -184,6 +193,19 @@ internal SentryUnityOptions ToSentryUnityOptions( XboxNativeSupportEnabled = XboxNativeSupportEnabled, Il2CppLineNumberSupportEnabled = Il2CppLineNumberSupportEnabled, PerformanceAutoInstrumentationEnabled = AutoAwakeTraces, + Experimental = new SentryUnityExperimentalOptions + { + EnableLogs = EnableStructuredLogging, + CaptureStructuredLogsForLogType = + { + [LogType.Log] = StructuredLogOnDebugLog, + [LogType.Warning] = StructuredLogOnDebugLogWarning, + [LogType.Assert] = StructuredLogOnDebugLogAssertion, + [LogType.Error] = StructuredLogOnDebugLogError, + [LogType.Exception] = StructuredLogOnDebugLogException + }, + AttachBreadcrumbsToEvents = AttachBreadcrumbsToEvents + } }; // By default, the cacheDirectoryPath gets set on known platforms. We're overwriting this behaviour here. diff --git a/src/Sentry.Unity/SentrySdk.Dotnet.cs b/src/Sentry.Unity/SentrySdk.Dotnet.cs index b4ea8fb07..1248f3a11 100644 --- a/src/Sentry.Unity/SentrySdk.Dotnet.cs +++ b/src/Sentry.Unity/SentrySdk.Dotnet.cs @@ -51,6 +51,17 @@ public static partial class SentrySdk /// public static bool IsEnabled { [DebuggerStepThrough] get => Sentry.SentrySdk.IsEnabled; } + /// + /// Gets the structured logger instance for creating and sending logs to Sentry. + /// + /// + /// Use this property to access structured logging functionality. Logs are only sent when + /// 's + /// is set to true. + /// + /// + public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => Sentry.SentrySdk.Logger; } + /// /// Creates a new scope that will terminate when disposed. /// diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index e0008f79c..2555296a4 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -305,6 +305,13 @@ internal string? DefaultUserId internal ISentryUnityInfo UnityInfo { get; private set; } internal Action? PlatformConfiguration { get; private set; } + // Delegate to base property to ensure both Unity and base SDK reference the same instance + public new SentryUnityExperimentalOptions Experimental + { + get => (SentryUnityExperimentalOptions)base.Experimental; + set => base.Experimental = value; + } + public SentryUnityOptions() : this(isBuilding: false) { } // For testing @@ -319,6 +326,9 @@ internal SentryUnityOptions(IApplication? application = null, UnityInfo = unityInfo ?? SentryPlatformServices.UnityInfo; PlatformConfiguration = SentryPlatformServices.PlatformConfiguration; + // Initialize base.Experimental with Unity-specific experimental options + base.Experimental = new SentryUnityExperimentalOptions(); + application ??= ApplicationAdapter.Instance; behaviour ??= SentryMonoBehaviour.Instance; @@ -338,7 +348,7 @@ internal SentryUnityOptions(IApplication? application = null, // UnityLogHandlerIntegration is not compatible with WebGL, so it's added conditionally if (application.Platform != RuntimePlatform.WebGLPlayer) { - AddIntegration(new UnityLogHandlerIntegration(this)); + AddIntegration(new UnityLogHandlerIntegration()); AddIntegration(new UnityApplicationLoggingIntegration()); } @@ -490,3 +500,50 @@ public enum NativeInitializationType /// BuildTime, } + +/// +/// Unity-specific experimental options. +/// +/// +/// This extends the base with Unity-specific experimental features. +/// These options are subject to change in future versions. +/// +public sealed class SentryUnityExperimentalOptions : SentryOptions.SentryExperimentalOptions +{ + /// + /// Controls whether structured logs should be captured for each Unity log type. + /// + public Dictionary CaptureStructuredLogsForLogType { get; set; } + + /// + /// When set to true, breadcrumbs will be added on top of structured logging. + /// Defaults to false. + /// + public bool AttachBreadcrumbsToEvents { get; set; } = false; + + /// + /// Sets a callback function to be invoked before sending the log to Sentry. + /// When the delegate throws an during invocation, the log will not be captured. + /// + /// + /// It can be used to modify the log object before being sent to Sentry. + /// To prevent the log from being sent to Sentry, return . + /// + /// + public new void SetBeforeSendLog(Func beforeSendLog) + { + base.SetBeforeSendLog(beforeSendLog); + } + + internal SentryUnityExperimentalOptions() + { + CaptureStructuredLogsForLogType = new Dictionary + { + { LogType.Log, false }, + { LogType.Warning, true }, + { LogType.Assert, true }, + { LogType.Error, true }, + { LogType.Exception, true } + }; + } +} diff --git a/test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs b/test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs new file mode 100644 index 000000000..52b5c10ba --- /dev/null +++ b/test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace Sentry.Unity.Tests.Stubs; + +internal sealed class TestStructuredLogger : SentryStructuredLogger +{ + public List<(string level, string message, object[] args)> LogCalls { get; } = new(); + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + => LogCalls.Add((level.ToString(), template, parameters ?? [])); + + protected internal override void CaptureLog(SentryLog log) + { + // Not needed for our tests + } + + protected internal override void Flush() + { + // Not needed for our tests + } + + public void Clear() => LogCalls.Clear(); +} diff --git a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs index 03f948a24..7654d1644 100644 --- a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs @@ -175,5 +175,78 @@ public void OnLogMessageReceived_LogTypeException_CaptureExceptionsEnabled_Event Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); } + + [Test] + [TestCase(LogType.Log)] + [TestCase(LogType.Warning)] + [TestCase(LogType.Error)] + public void OnLogMessageReceived_ExperimentalLogsEnabledWithAttachBreadcrumbsFalse_BreadcrumbsNotAdded(LogType unityLogType) + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.AttachBreadcrumbsToEvents = false; + _fixture.SentryOptions.AddBreadcrumbsForLogType[unityLogType] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, string.Empty, unityLogType); + + Assert.AreEqual(0, _fixture.Hub.ConfigureScopeCalls.Count); + } + + [Test] + [TestCase(LogType.Log)] + [TestCase(LogType.Warning)] + [TestCase(LogType.Error)] + public void OnLogMessageReceived_ExperimentalLogsEnabledWithAttachBreadcrumbsTrue_BreadcrumbsAdded(LogType unityLogType) + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.AttachBreadcrumbsToEvents = true; + _fixture.SentryOptions.AddBreadcrumbsForLogType[unityLogType] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, string.Empty, unityLogType); + + var scope = new Scope(_fixture.SentryOptions); + _fixture.Hub.ConfigureScopeCalls.Single().Invoke(scope); + var breadcrumb = scope.Breadcrumbs.Single(); + + Assert.AreEqual(message, breadcrumb.Message); + Assert.AreEqual("unity.logger", breadcrumb.Category); + } + + [Test] + [TestCase(LogType.Log)] + [TestCase(LogType.Warning)] + [TestCase(LogType.Error)] + public void OnLogMessageReceived_ExperimentalLogsDisabled_BreadcrumbsAddedAsNormal(LogType unityLogType) + { + _fixture.SentryOptions.Experimental.EnableLogs = false; + _fixture.SentryOptions.AddBreadcrumbsForLogType[unityLogType] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, string.Empty, unityLogType); + + var scope = new Scope(_fixture.SentryOptions); + _fixture.Hub.ConfigureScopeCalls.Single().Invoke(scope); + var breadcrumb = scope.Breadcrumbs.Single(); + + Assert.AreEqual(message, breadcrumb.Message); + Assert.AreEqual("unity.logger", breadcrumb.Category); + } + + [Test] + public void OnLogMessageReceived_ExceptionType_NoBreadcrumbAdded() + { + _fixture.SentryOptions.AddBreadcrumbsForLogType[LogType.Exception] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, "stacktrace", LogType.Exception); + + // Exception breadcrumbs are handled by the .NET SDK, not by this integration + Assert.AreEqual(0, _fixture.Hub.ConfigureScopeCalls.Count); + } } } diff --git a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs index 9070daa95..7edff354c 100644 --- a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs @@ -1,11 +1,13 @@ using System; using System.Linq; using NUnit.Framework; +using Sentry.Internal; using Sentry.Protocol; using Sentry.Unity.Integrations; using Sentry.Unity.Tests.SharedClasses; using Sentry.Unity.Tests.Stubs; using UnityEngine; +using UnityEngine.TestTools; namespace Sentry.Unity.Tests; @@ -15,11 +17,14 @@ private class Fixture { public TestHub Hub { get; set; } = null!; public SentryUnityOptions SentryOptions { get; set; } = null!; + public TestStructuredLogger? StructuredLogger { get; set; } public UnityLogHandlerIntegration GetSut() { var application = new TestApplication(); - var integration = new UnityLogHandlerIntegration(SentryOptions, application); + var integration = StructuredLogger != null + ? new UnityLogHandlerIntegration(application, () => StructuredLogger) + : new UnityLogHandlerIntegration(application, () => DisabledSentryStructuredLogger.Instance); integration.Register(Hub, SentryOptions); return integration; } @@ -41,9 +46,9 @@ public void SetUp() public void CaptureException_ExceptionCapturedAndMechanismSet() { var sut = _fixture.GetSut(); - var message = NUnit.Framework.TestContext.CurrentContext.Test.Name; + var message = "test message" + Guid.NewGuid(); - sut.CaptureException(new Exception(message), null); + sut.ProcessException(new Exception(message), null); Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); @@ -75,4 +80,101 @@ public void Register_RegisteredASecondTime_LogsWarningAndReturns() log.logLevel == SentryLevel.Warning && log.message.Contains("UnityLogHandlerIntegration has already been registered."))); } + + [Test] + public void ProcessException_ExperimentalCaptureEnabled_CapturesStructuredLog() + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Exception] = true; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.ProcessException(new Exception(message), null); + + Assert.AreEqual(1, _fixture.StructuredLogger.LogCalls.Count); + var logCall = _fixture.StructuredLogger.LogCalls.Single(); + Assert.AreEqual("Error", logCall.level); + Assert.AreEqual(message, logCall.message); + } + + [Test] + public void ProcessException_ExperimentalCaptureDisabled_DoesNotCaptureStructuredLog() + { + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Exception] = false; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.ProcessException(new Exception(message), null); + + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + + [Test] + public void LogFormat_WithSentryLogTag_DoesNotCaptureStructuredLog() + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Error] = true; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + + const string? format = "{0}: {1}"; + const string? message = "Test message"; + LogAssert.Expect(LogType.Error, string.Format(format, UnityLogger.LogTag, message)); + + sut.LogFormat(LogType.Error, null, format, UnityLogger.LogTag, message); + + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + + [Test] + public void LogFormat_WithEnableLogsFalse_DoesNotCaptureStructuredLog() + { + _fixture.SentryOptions.Experimental.EnableLogs = false; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Error] = true; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + LogAssert.Expect(LogType.Error, message); + + sut.LogFormat(LogType.Error, null, message); + + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + + [Test] + [TestCase(LogType.Log, "Info", true)] + [TestCase(LogType.Log, "Info", false)] + [TestCase(LogType.Warning, "Warning", true)] + [TestCase(LogType.Warning, "Warning", false)] + [TestCase(LogType.Error, "Error", true)] + [TestCase(LogType.Error, "Error", false)] + [TestCase(LogType.Assert, "Error", true)] + [TestCase(LogType.Assert, "Error", false)] + public void LogFormat_WithExperimentalFlag_CapturesStructuredLogWhenEnabled(LogType logType, string expectedLevel, bool captureEnabled) + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[logType] = captureEnabled; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + LogAssert.Expect(logType, message); + + sut.LogFormat(logType, null, message); + + if (captureEnabled) + { + Assert.AreEqual(1, _fixture.StructuredLogger.LogCalls.Count); + var logCall = _fixture.StructuredLogger.LogCalls.Single(); + Assert.AreEqual(expectedLevel, logCall.level); + Assert.AreEqual(message, logCall.message); + } + else + { + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + } } From 70a7c7f15b86b7e7520a620a7a875185b7bf57b9 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Thu, 30 Oct 2025 15:18:20 +0100 Subject: [PATCH 11/20] feat: Mark exceptions as `NonTerminal` (#2376) --- CHANGELOG.md | 1 + src/Sentry.Unity/Integrations/UnityErrorLogException.cs | 7 ++++--- .../Integrations/UnityLogHandlerIntegration.cs | 3 +-- test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs | 8 ++++++-- .../Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs | 3 +++ 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a89e9fdcd..7966f5284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Features +- 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)) ### Dependencies diff --git a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs index c1dcf6237..9b163f632 100644 --- a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs +++ b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs @@ -23,8 +23,7 @@ internal class UnityErrorLogException : Exception private readonly SentryOptions? _options; private readonly IDiagnosticLogger? _logger; - public UnityErrorLogException(string logString, string logStackTrace, SentryOptions? options) - : base(logString) + public UnityErrorLogException(string logString, string logStackTrace, SentryOptions? options) : base(logString) { _logString = logString; _logStackTrace = logStackTrace; @@ -55,7 +54,9 @@ public SentryException ToSentryException() Mechanism = new Mechanism { Handled = true, - Type = "unity.log" + Type = "unity.log", + Synthetic = true, + Data = { { Mechanism.TerminalKey, false } } } }; } diff --git a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs index d869a4ceb..4318f3474 100644 --- a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs @@ -77,8 +77,7 @@ internal void ProcessException(Exception exception, UnityEngine.Object? context) // NOTE: This might not be entirely true, as a user could as well call `Debug.LogException` // and expect a handled exception but it is not possible for us to differentiate // https://docs.sentry.io/platforms/unity/troubleshooting/#unhandled-exceptions---debuglogexception - exception.Data[Mechanism.HandledKey] = false; - exception.Data[Mechanism.MechanismKey] = "Unity.LogException"; + exception.SetSentryMechanism("Unity.LogException", handled: false, terminal: false); _ = _hub.CaptureException(exception); if (_options.Experimental.CaptureStructuredLogsForLogType.TryGetValue(LogType.Exception, out var captureException) && captureException) diff --git a/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs b/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs index 0ce5990d7..f49c237b4 100644 --- a/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs +++ b/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs @@ -93,7 +93,9 @@ public void ToSentryException_ParsingTestCases( Mechanism = new Mechanism { Handled = true, - Type = "unity.log" + Type = "unity.log", + Synthetic = true, + Data = { {Mechanism.TerminalKey, false} } } } }, @@ -189,7 +191,9 @@ public void ToSentryException_ParsingTestCases( Mechanism = new Mechanism { Handled = true, - Type = "unity.log" + Type = "unity.log", + Synthetic = true, + Data = { {Mechanism.TerminalKey, false} } } } } diff --git a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs index 7edff354c..750cb8bd1 100644 --- a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs @@ -63,6 +63,9 @@ public void CaptureException_ExceptionCapturedAndMechanismSet() Assert.IsTrue(capturedEvent.Exception!.Data.Contains(Mechanism.MechanismKey)); Assert.AreEqual("Unity.LogException", (string)capturedEvent.Exception!.Data[Mechanism.MechanismKey]); + + Assert.IsTrue(capturedEvent.Exception!.Data.Contains(Mechanism.TerminalKey)); + Assert.IsFalse((bool)capturedEvent.Exception!.Data[Mechanism.TerminalKey]); } [Test] From d5890a70964d70f16e107f9246f8004fb8c947c1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 30 Oct 2025 16:49:52 +0100 Subject: [PATCH 12/20] bumped to include the open change --- 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 ba6262302c7fee65308985df9c1762fb89aa3ec1 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Thu, 30 Oct 2025 17:46:12 +0100 Subject: [PATCH 13/20] feat: `LifeCycleIntegration` and changes to trace generation (#2374) --- CHANGELOG.md | 4 + .../Integrations/LifeCycleIntegration.cs | 81 +++++++++++++++++++ .../Integrations/SessionIntegration.cs | 33 -------- .../TraceGenerationIntegration.cs | 6 -- .../UnityLogHandlerIntegration.cs | 30 +------ src/Sentry.Unity/SentryUnityOptions.cs | 2 +- ...nTests.cs => LifeCycleIntegrationTests.cs} | 2 +- .../TraceGenerationIntegrationTests.cs | 21 ----- .../UnityLogHandlerIntegrationTests.cs | 5 +- 9 files changed, 90 insertions(+), 94 deletions(-) create mode 100644 src/Sentry.Unity/Integrations/LifeCycleIntegration.cs delete mode 100644 src/Sentry.Unity/Integrations/SessionIntegration.cs rename test/Sentry.Unity.Tests/{SessionIntegrationTests.cs => LifeCycleIntegrationTests.cs} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7966f5284..d26855c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - `sentry-native` is now built on Ubuntu 22.04 instead of Ubuntu 20.04, which reached EOL in May 2025. If you are running you game on a server on Ubuntu 20.04, you should update the OS before upgrading to this SDK version. ([#2355](https://github.com/getsentry/sentry-unity/pull/2355)) +### Behavioural Changes + +- The SDK no longer refreshes the trace ID when the app loses and regains focus. This means that the trace ID persists from game start to game end. The SDK now also automatically adds breadcrumbs on those lifecycle events. ([#2374](https://github.com/getsentry/sentry-unity/pull/2374)) + ### Features - 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)) diff --git a/src/Sentry.Unity/Integrations/LifeCycleIntegration.cs b/src/Sentry.Unity/Integrations/LifeCycleIntegration.cs new file mode 100644 index 000000000..7a315221c --- /dev/null +++ b/src/Sentry.Unity/Integrations/LifeCycleIntegration.cs @@ -0,0 +1,81 @@ +using System; +using Sentry.Extensibility; +using Sentry.Integrations; + +namespace Sentry.Unity.Integrations; + +internal class LifeCycleIntegration : ISdkIntegration +{ + private IHub? _hub; + private SentryUnityOptions _options = null!; // Set during register + + private readonly SentryMonoBehaviour _sentryMonoBehaviour; + private readonly IApplication _application; + + public LifeCycleIntegration(SentryMonoBehaviour sentryMonoBehaviour, IApplication? application = null) + { + _application = application ?? ApplicationAdapter.Instance; + _sentryMonoBehaviour = sentryMonoBehaviour; + } + + public void Register(IHub hub, SentryOptions sentryOptions) + { + _hub = hub; + // This should never happen, but if it does... + _options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'."); + + if (!_options.AutoSessionTracking) + { + return; + } + + _sentryMonoBehaviour.ApplicationResuming += () => + { + if (!hub.IsEnabled) + { + return; + } + + hub.AddBreadcrumb(message: "App regained focus.", category: "app.lifecycle"); + + _options.DiagnosticLogger?.LogDebug("Resuming session."); + hub.ResumeSession(); + }; + _sentryMonoBehaviour.ApplicationPausing += () => + { + if (!hub.IsEnabled) + { + return; + } + + hub.AddBreadcrumb(message: "App lost focus.", category: "app.lifecycle"); + + _options.DiagnosticLogger?.LogDebug("Pausing session."); + hub.PauseSession(); + }; + + _application.Quitting += OnQuitting; + } + + private void OnQuitting() + { + // Platform-specific behavior notes: + // - iOS: Applications are usually suspended and do not quit. If `Exit on Suspend` is enabled in Player Settings, + // the application will be terminated on suspend instead of calling this method. In that case, + // `OnApplicationPause` will be called instead. + // - Windows Store Apps/Windows Phone 8.1: No application quit event exists. Use OnApplicationFocus instead. + // - WebGL: OnApplicationQuit cannot be implemented due to browser tab closing behavior. + + // Session handling on shutdown: + // This method is invoked even when an uncaught exception occurs (including crashes in native layers). + // We pause the session here rather than ending it to ensure the .NET SDK can properly detect crashes + // on the next startup (via the CrashedLastRun callback). The session will then be closed with the + // correct timestamp during initialization. + if (_options.AutoSessionTracking) + { + _hub?.PauseSession(); + } + + _hub?.FlushAsync(_options.ShutdownTimeout).GetAwaiter().GetResult(); + } +} diff --git a/src/Sentry.Unity/Integrations/SessionIntegration.cs b/src/Sentry.Unity/Integrations/SessionIntegration.cs deleted file mode 100644 index c966d2227..000000000 --- a/src/Sentry.Unity/Integrations/SessionIntegration.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Sentry.Extensibility; -using Sentry.Integrations; - -namespace Sentry.Unity.Integrations; - -internal class SessionIntegration : ISdkIntegration -{ - private readonly SentryMonoBehaviour _sentryMonoBehaviour; - - public SessionIntegration(SentryMonoBehaviour sentryMonoBehaviour) - { - _sentryMonoBehaviour = sentryMonoBehaviour; - } - - public void Register(IHub hub, SentryOptions options) - { - if (!options.AutoSessionTracking) - { - return; - } - - _sentryMonoBehaviour.ApplicationResuming += () => - { - options.DiagnosticLogger?.LogDebug("Resuming session."); - hub.ResumeSession(); - }; - _sentryMonoBehaviour.ApplicationPausing += () => - { - options.DiagnosticLogger?.LogDebug("Pausing session."); - hub.PauseSession(); - }; - } -} diff --git a/src/Sentry.Unity/Integrations/TraceGenerationIntegration.cs b/src/Sentry.Unity/Integrations/TraceGenerationIntegration.cs index fa9c12b4d..42fc56c73 100644 --- a/src/Sentry.Unity/Integrations/TraceGenerationIntegration.cs +++ b/src/Sentry.Unity/Integrations/TraceGenerationIntegration.cs @@ -27,12 +27,6 @@ public void Register(IHub hub, SentryOptions options) return; } - _sentryMonoBehaviour.ApplicationResuming += () => - { - options.DiagnosticLogger?.LogDebug("Game resuming. Creating new Trace."); - hub.ConfigureScope(scope => scope.SetPropagationContext(new SentryPropagationContext())); - }; - var isTracingEnabled = unityOptions.TracesSampleRate > 0.0f; // Create initial trace context if tracing is disabled or startup tracing is disabled diff --git a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs index 4318f3474..90f8b9a7f 100644 --- a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs @@ -12,21 +12,14 @@ namespace Sentry.Unity.Integrations; /// internal sealed class UnityLogHandlerIntegration : ISdkIntegration, ILogHandler { - private readonly IApplication _application; private readonly Func? _loggerFactory; private IHub? _hub; private SentryUnityOptions _options = null!; // Set during register private ILogHandler _unityLogHandler = null!; // Set during register private SentryStructuredLogger _structuredLogger = null!; // Set during register - public UnityLogHandlerIntegration(IApplication? application = null) - { - _application = application ?? ApplicationAdapter.Instance; - } - // For testing: allows injecting a custom logger factory - internal UnityLogHandlerIntegration(IApplication? application, Func loggerFactory) - : this(application) + internal UnityLogHandlerIntegration(Func? loggerFactory = null) { _loggerFactory = loggerFactory; } @@ -48,8 +41,6 @@ public void Register(IHub hub, SentryOptions sentryOptions) _unityLogHandler = Debug.unityLogger.logHandler; Debug.unityLogger.logHandler = this; - - _application.Quitting += OnQuitting; } public void LogException(Exception exception, UnityEngine.Object context) @@ -141,23 +132,4 @@ private void ProcessStructuredLog(LogType logType, string format, params object[ break; } } - - private void OnQuitting() - { - _options.DiagnosticLogger?.LogInfo("OnQuitting was invoked. Unhooking log callback and pausing session."); - - // Note: iOS applications are usually suspended and do not quit. You should tick "Exit on Suspend" in Player settings for iOS builds to cause the game to quit and not suspend, otherwise you may not see this call. - // If "Exit on Suspend" is not ticked then you will see calls to OnApplicationPause instead. - // Note: On Windows Store Apps and Windows Phone 8.1 there is no application quit event. Consider using OnApplicationFocus event when focusStatus equals false. - // Note: On WebGL it is not possible to implement OnApplicationQuit due to nature of the browser tabs closing. - - // 'OnQuitting' is invoked even when an uncaught exception happens in the ART. To make sure the .NET - // SDK checks with the native layer on restart if the previous run crashed (through the CrashedLastRun callback) - // we'll just pause sessions on shutdown. On restart they can be closed with the right timestamp and as 'exited'. - if (_options.AutoSessionTracking) - { - _hub?.PauseSession(); - } - _hub?.FlushAsync(_options.ShutdownTimeout).GetAwaiter().GetResult(); - } } diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 2555296a4..5dfcaa994 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -358,7 +358,7 @@ internal SentryUnityOptions(IApplication? application = null, AddIntegration(new UnityBeforeSceneLoadIntegration()); AddIntegration(new SceneManagerIntegration()); AddIntegration(new SceneManagerTracingIntegration()); - AddIntegration(new SessionIntegration(behaviour)); + AddIntegration(new LifeCycleIntegration(behaviour)); AddIntegration(new TraceGenerationIntegration(behaviour)); AddExceptionFilter(new UnityBadGatewayExceptionFilter()); diff --git a/test/Sentry.Unity.Tests/SessionIntegrationTests.cs b/test/Sentry.Unity.Tests/LifeCycleIntegrationTests.cs similarity index 96% rename from test/Sentry.Unity.Tests/SessionIntegrationTests.cs rename to test/Sentry.Unity.Tests/LifeCycleIntegrationTests.cs index d53f81698..598715499 100644 --- a/test/Sentry.Unity.Tests/SessionIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/LifeCycleIntegrationTests.cs @@ -6,7 +6,7 @@ namespace Sentry.Unity.Tests; -public class SessionIntegrationTests +public class LifeCycleIntegrationTests { [UnityTest] public IEnumerator SessionIntegration_Init_SentryMonoBehaviourCreated() diff --git a/test/Sentry.Unity.Tests/TraceGenerationIntegrationTests.cs b/test/Sentry.Unity.Tests/TraceGenerationIntegrationTests.cs index 9e53f0393..95547f515 100644 --- a/test/Sentry.Unity.Tests/TraceGenerationIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/TraceGenerationIntegrationTests.cs @@ -68,27 +68,6 @@ public void Register_TracingEnabledAndAutoStartupTracesEnabled_DoesNotGenerateIn Assert.IsEmpty(_fixture.TestHub.ConfigureScopeCalls); } - [Test] - public void ApplicationResuming_WhenCalled_GeneratesNewTrace() - { - // Arrange - var sut = _fixture.GetSut(); - sut.Register(_fixture.TestHub, _fixture.SentryOptions); - var initialCallsCount = _fixture.TestHub.ConfigureScopeCalls.Count; - - // Act - _fixture.SentryMonoBehaviour.ResumeApplication(); - - // Assert - Assert.AreEqual(initialCallsCount + 1, _fixture.TestHub.ConfigureScopeCalls.Count); - var configureScope = _fixture.TestHub.ConfigureScopeCalls.Last(); - var scope = new Scope(_fixture.SentryOptions); - var initialPropagationContext = scope.PropagationContext; - configureScope(scope); - - Assert.AreNotEqual(initialPropagationContext, scope.PropagationContext); - } - [TestCase(0.0f, false)] [TestCase(0.0f, true)] [TestCase(1.0f, false)] diff --git a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs index 750cb8bd1..143591080 100644 --- a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs @@ -21,10 +21,9 @@ private class Fixture public UnityLogHandlerIntegration GetSut() { - var application = new TestApplication(); var integration = StructuredLogger != null - ? new UnityLogHandlerIntegration(application, () => StructuredLogger) - : new UnityLogHandlerIntegration(application, () => DisabledSentryStructuredLogger.Instance); + ? new UnityLogHandlerIntegration(() => StructuredLogger) + : new UnityLogHandlerIntegration(() => DisabledSentryStructuredLogger.Instance); integration.Register(Hub, SentryOptions); return integration; } From 1308c56114c351b20ce1e0d53cdffe90633eb26c Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Mon, 3 Nov 2025 15:24:12 +0100 Subject: [PATCH 14/20] fix: Capture `Debug.LogError` as message, not exception (#2377) --- CHANGELOG.md | 4 + src/Sentry.Unity/Il2CppEventProcessor.cs | 13 +- .../UnityApplicationLoggingIntegration.cs | 14 +- .../Integrations/UnityErrorLogException.cs | 173 ------------ .../Integrations/UnityLogEventFactory.cs | 88 ++++++ .../Integrations/UnityStackTraceParser.cs | 128 +++++++++ src/Sentry.Unity/SentryUnityOptions.cs | 1 - src/Sentry.Unity/UnityExceptionProcessor.cs | 17 -- test/Sentry.Unity.Tests/IntegrationTests.cs | 10 +- ...UnityApplicationLoggingIntegrationTests.cs | 42 +++ .../UnityErrorLogExceptionTests.cs | 250 ------------------ .../UnityLogEventFactoryTests.cs | 114 ++++++++ .../UnityStackTraceParserTests.cs | 182 +++++++++++++ 13 files changed, 575 insertions(+), 461 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 src/Sentry.Unity/UnityExceptionProcessor.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/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)) diff --git a/src/Sentry.Unity/Il2CppEventProcessor.cs b/src/Sentry.Unity/Il2CppEventProcessor.cs index 7d525ebc1..e7c4b83d5 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) { @@ -54,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/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 7c222aaf9..10d329003 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, false, _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 9b163f632..000000000 --- a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs +++ /dev/null @@ -1,173 +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); - 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", - Synthetic = true, - Data = { { Mechanism.TerminalKey, false } } - } - }; - } - - private const string AtFileMarker = " (at "; - - private List ParseStackTrace(string stackTrace) - { - // 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, _logger); - 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..135f65a19 --- /dev/null +++ b/src/Sentry.Unity/Integrations/UnityLogEventFactory.cs @@ -0,0 +1,88 @@ +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 + /// 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); + frames.Reverse(); + + return new SentryEvent + { + SentryExceptions = [new SentryException + { + Stacktrace = new SentryStackTrace { Frames = frames }, + Value = message, + Type = "LogException", + Mechanism = new Mechanism + { + Handled = handled, + Type = "unity.log", + Terminal = false, + Synthetic = true + } + }], + 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 } + }; + } +} 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 5dfcaa994..34385198e 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/src/Sentry.Unity/UnityExceptionProcessor.cs b/src/Sentry.Unity/UnityExceptionProcessor.cs deleted file mode 100644 index 4efec1ecd..000000000 --- a/src/Sentry.Unity/UnityExceptionProcessor.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using Sentry.Extensibility; -using Sentry.Unity.Integrations; - -namespace Sentry.Unity; - -public class UnityExceptionProcessor : ISentryEventExceptionProcessor -{ - public void Process(Exception exception, SentryEvent sentryEvent) - { - if (exception is UnityErrorLogException ule) - { - sentryEvent.SentryExceptions = [ule.ToSentryException()]; - sentryEvent.SetTag("source", "log"); - } - } -} diff --git a/test/Sentry.Unity.Tests/IntegrationTests.cs b/test/Sentry.Unity.Tests/IntegrationTests.cs index 23f9def4f..11176be22 100644 --- a/test/Sentry.Unity.Tests/IntegrationTests.cs +++ b/test/Sentry.Unity.Tests/IntegrationTests.cs @@ -226,7 +226,8 @@ public IEnumerator DebugLogError_OnMainThread_IsCapturedAndIsMainThreadIsTrue() { 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,7 @@ public IEnumerator DebugLogError_OnMainThread_IsCapturedAndIsMainThreadIsTrue() 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\":\"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/UnityApplicationLoggingIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs index 7654d1644..4f6ba8460 100644 --- a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs @@ -248,5 +248,47 @@ 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_LogErrorAttachStackTraceTrue_CapturesMessageWithThread() + { + _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]; + + Assert.NotNull(capturedEvent.Message); + Assert.AreEqual(message, capturedEvent.Message!.Message); + Assert.IsEmpty(capturedEvent.SentryExceptions); + + 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_LogErrorAttachStackTraceFalse_CaptureMessageWithNoStackTrace() + { + _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]; + + 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 f49c237b4..000000000 --- a/test/Sentry.Unity.Tests/UnityErrorLogExceptionTests.cs +++ /dev/null @@ -1,250 +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", - Synthetic = true, - Data = { {Mechanism.TerminalKey, false} } - } - } - }, - // 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", - Synthetic = true, - Data = { {Mechanism.TerminalKey, false} } - } - } - } - ]; - - 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..39f6bc089 --- /dev/null +++ b/test/Sentry.Unity.Tests/UnityLogEventFactoryTests.cs @@ -0,0 +1,114 @@ +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] + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_CreatesExceptionEvent(bool handled) + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + 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] + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasExpectedProperties(bool handled) + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + 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.AreEqual(exception.Mechanism!.Handled, handled); + Assert.AreEqual("unity.log", exception.Mechanism.Type); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_ExceptionHasStackTrace(bool handled) + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + 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] + [TestCase(true)] + [TestCase(false)] + public void CreateExceptionEvent_ValidStackTrace_FramesAreReversed(bool handled) + { + var evt = UnityLogEventFactory.CreateExceptionEvent( + 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); + } +} 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 223821e05f20411007d27911b543a23bf507e40d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 15:34:58 +0100 Subject: [PATCH 15/20] Bumped 'version6' --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index cc7d90436..a739982d5 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit cc7d904364bb437f6e9eb9225fc0fd2858ef9177 +Subproject commit a739982d5d5b33a0bef0e8dcc4e17f70ab0a7e76 From 97fb489de9f5c83a838637d03809aeec99ad3479 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 15:39:27 +0100 Subject: [PATCH 16/20] Stop changing the submodule.. --- src/sentry-dotnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry-dotnet b/src/sentry-dotnet index d69e9327b..a739982d5 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit d69e9327b0da5e8ac0d1c10d0f9fa8f82ed94e7d +Subproject commit a739982d5d5b33a0bef0e8dcc4e17f70ab0a7e76 From 2e94667e24d6c6804c9d5a894e12066882708630 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 15:52:04 +0100 Subject: [PATCH 17/20] Updated CHANGELOG.md --- CHANGELOG.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b06e7ee..5d9b2c3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ - `sentry-native` is now built on Ubuntu 22.04 instead of Ubuntu 20.04, which reached EOL in May 2025. If you are running you game on a server on Ubuntu 20.04, you should update the OS before upgrading to this SDK version. ([#2355](https://github.com/getsentry/sentry-unity/pull/2355)) +#### Bump to Sentry SDK for .NET v6.0.0-preview introduces the following changes + +- `BreadcrumbLevel.Critical` has been renamed to `BreadcrumbLevel.Fatal` for consistency with the other Sentry SDKs ([#4605](https://github.com/getsentry/sentry-dotnet/pull/4605)) +- Removed `SentrySdk.CaptureUserFeedback` and all associated members. Use the newer `SentrySdk.CaptureFeedback` instead. ([#4619](https://github.com/getsentry/sentry-dotnet/pull/4619)) +- ScopeExtensions.Populate is now internal ([#4611](https://github.com/getsentry/sentry-dotnet/pull/4611)) +- Backpressure handling is now enabled by default, meaning that the SDK will monitor system health and reduce the sampling rate of events and transactions when the system is under load. When the system is determined to be healthy again, the sampling rates are returned to their original levels. ([#4615](https://github.com/getsentry/sentry-dotnet/pull/4615)) +- Spans and Transactions now implement `IDisposable` so that they can be used with `using` statements/declarations that will automatically finish the span with a status of OK when it passes out of scope, if it has not already been finished, to be consistent with `Activity` classes when using OpenTelemetry ([#4627](https://github.com/getsentry/sentry-dotnet/pull/4627)) +- SpanTracer and TransactionTracer are still public but these are now `sealed` (see also [#4627](https://github.com/getsentry/sentry-dotnet/pull/4627)) +- CaptureFeedback now returns a `SentryId` and a `CaptureFeedbackResult` out parameter that indicate whether feedback was captured successfully and what the reason for failure was otherwise ([#4613](https://github.com/getsentry/sentry-dotnet/pull/4613)) + ### Behavioural Changes - The SDK no longer refreshes the trace ID when the app loses and regains focus. This means that the trace ID persists from game start to game end. The SDK now also automatically adds breadcrumbs on those lifecycle events. ([#2374](https://github.com/getsentry/sentry-unity/pull/2374)) @@ -27,9 +37,9 @@ - Bump CLI from v2.56.0 to v2.57.0 ([#2356](https://github.com/getsentry/sentry-unity/pull/2356), [#2369](https://github.com/getsentry/sentry-unity/pull/2369)) - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2570) - [diff](https://github.com/getsentry/sentry-cli/compare/2.56.0...2.57.0) -- Bump .NET SDK from v5.16.0 to v5.16.2 ([#2359](https://github.com/getsentry/sentry-unity/pull/2359), [#2384](https://github.com/getsentry/sentry-unity/pull/2384)) - - [changelog](https://github.com/getsentry/sentry-dotnet/blob/main/CHANGELOG.md#5162) - - [diff](https://github.com/getsentry/sentry-dotnet/compare/5.16.0...5.16.2) +- Bump .NET SDK from v5.16.0 to v6.0.0-preview.2 ([#2359](https://github.com/getsentry/sentry-unity/pull/2359), [#2384](https://github.com/getsentry/sentry-unity/pull/2384), [#2364](https://github.com/getsentry/sentry-unity/pull/2364)) + - [changelog](https://github.com/getsentry/sentry-dotnet/blob/main/CHANGELOG.md#6000-preview.2) + - [diff](https://github.com/getsentry/sentry-dotnet/compare/5.16.0...6.0.0-peview.2) - Bump Native SDK from v0.11.2 to v0.12.0 ([#2357](https://github.com/getsentry/sentry-unity/pull/2357), [#2378](https://github.com/getsentry/sentry-unity/pull/2378)) - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0120) - [diff](https://github.com/getsentry/sentry-native/compare/0.11.2...0.12.0) From 77d1fc99d5bf2bf680247e7f8a5b209c082f4906 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 15:55:20 +0100 Subject: [PATCH 18/20] Testhub slipped through --- test/Sentry.Unity.Tests/Stubs/TestHub.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/Sentry.Unity.Tests/Stubs/TestHub.cs b/test/Sentry.Unity.Tests/Stubs/TestHub.cs index c64f402a8..7515f010e 100644 --- a/test/Sentry.Unity.Tests/Stubs/TestHub.cs +++ b/test/Sentry.Unity.Tests/Stubs/TestHub.cs @@ -31,6 +31,12 @@ public SentryId CaptureEvent(SentryEvent evt, Scope? scope = null, SentryHint? h return evt.EventId; } + public SentryId CaptureFeedback(SentryFeedback feedback, out CaptureFeedbackResult result, Scope? scope = null, + SentryHint? hint = null) + { + throw new NotImplementedException(); + } + public void CaptureFeedback(SentryFeedback feedback, Scope? scope = null, SentryHint? hint = null) { throw new NotImplementedException(); @@ -192,6 +198,12 @@ public SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Action co throw new NotImplementedException(); } + public SentryId CaptureFeedback(SentryFeedback feedback, out CaptureFeedbackResult result, Action configureScope, + SentryHint? hint = null) + { + throw new NotImplementedException(); + } + public void CaptureFeedback(SentryFeedback feedback, Action configureScope, SentryHint? hint = null) { throw new NotImplementedException(); From c491ae26e4ab6d276417bb341c4294c2394a6c65 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 3 Nov 2025 17:09:34 +0100 Subject: [PATCH 19/20] First iteration --- .../ConfigurationWindow/LoggingTab.cs | 25 +---- src/Sentry.Unity/ContentDebounce.cs | 76 +++++++++++++ .../UnityApplicationLoggingIntegration.cs | 22 +--- .../UnityLogHandlerIntegration.cs | 17 +++ .../ScriptableSentryUnityOptions.cs | 8 +- src/Sentry.Unity/SentryUnityOptions.cs | 23 ++-- src/Sentry.Unity/TimeDebounceBase.cs | 55 --------- test/Sentry.Unity.Tests/DebouncerTests.cs | 106 +++++++++++++----- 8 files changed, 200 insertions(+), 132 deletions(-) create mode 100644 src/Sentry.Unity/ContentDebounce.cs delete mode 100644 src/Sentry.Unity/TimeDebounceBase.cs diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs index 4a33285f3..9c9f11f22 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs @@ -125,29 +125,16 @@ internal static void Display(ScriptableSentryUnityOptions options) { options.EnableLogDebouncing = EditorGUILayout.BeginToggleGroup( new GUIContent("Enable Log Debouncing", "The SDK debounces log messages of the " + - "same type if they are more frequent than once per second."), + "same type when they happen within the same DebounceTimeWindow."), options.EnableLogDebouncing); EditorGUI.indentLevel++; - options.DebounceTimeLog = EditorGUILayout.IntField( - new GUIContent("Log Debounce [ms]", "The time that has to pass between events of " + - "LogType.Log before the SDK sends it again."), - options.DebounceTimeLog); - options.DebounceTimeLog = Math.Max(0, options.DebounceTimeLog); - - options.DebounceTimeWarning = EditorGUILayout.IntField( - new GUIContent("Warning Debounce [ms]", "The time that has to pass between events of " + - "LogType.Warning before the SDK sends it again."), - options.DebounceTimeWarning); - options.DebounceTimeWarning = Math.Max(0, options.DebounceTimeWarning); - - options.DebounceTimeError = EditorGUILayout.IntField( - new GUIContent("Error Debounce [ms]", "The time that has to pass between events of " + - "LogType.Assert, LogType.Exception and LogType.Error before " + - "the SDK sends it again."), - options.DebounceTimeError); - options.DebounceTimeError = Math.Max(0, options.DebounceTimeError); + options.DebounceTimeWindow = EditorGUILayout.IntField( + new GUIContent("Debounce Time Window [ms]", "The time that has to pass between log events " + + "before the SDK captures and sends them again."), + options.DebounceTimeWindow); + options.DebounceTimeWindow = Math.Max(0, options.DebounceTimeWindow); EditorGUI.indentLevel--; EditorGUILayout.EndToggleGroup(); diff --git a/src/Sentry.Unity/ContentDebounce.cs b/src/Sentry.Unity/ContentDebounce.cs new file mode 100644 index 000000000..e076a8dc7 --- /dev/null +++ b/src/Sentry.Unity/ContentDebounce.cs @@ -0,0 +1,76 @@ +using System; +using UnityEngine; + +namespace Sentry.Unity; + +/// +/// Interface for log message deduplication. +/// +public interface IUnityLogMessageDebounce +{ + /// + /// Checks if a log message should be debounced based on its content. + /// Returns true if the message should be allowed through, false if it should be blocked. + /// + bool Debounced(string message, string stacktrace, LogType logType); +} + +/// +/// Content-based debounce that deduplicates log messages based on their content hash. +/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback. +/// +public class ContentDebounce : IUnityLogMessageDebounce +{ + private static DateTimeOffset Now => DateTimeOffset.UtcNow; + + private readonly struct LogEntry + { + public readonly int Hash; + public readonly DateTimeOffset Timestamp; + + public LogEntry(int hash, DateTimeOffset timestamp) + { + Hash = hash; + Timestamp = timestamp; + } + } + + private readonly TimeSpan _debounceWindow; + private readonly LogEntry[] _ringBuffer; + private int _head; + + public ContentDebounce(TimeSpan debounceWindow, int bufferSize = 100) + { + _debounceWindow = debounceWindow; + _ringBuffer = new LogEntry[bufferSize]; + _head = 0; + } + + /// + /// Checks if the log content should be debounced. + /// Returns true if the message should be allowed through, false if it should be blocked. + /// + public bool Debounced(string message, string stacktrace, LogType logType) + { + var contentHash = HashCode.Combine(message, stacktrace); + var currentTime = Now; + + foreach (var entry in _ringBuffer) + { + if (entry.Hash != contentHash || entry.Timestamp == default) + { + continue; + } + + var timeSinceLastSeen = currentTime - entry.Timestamp; + if (timeSinceLastSeen < _debounceWindow) + { + return false; + } + } + + _ringBuffer[_head] = new LogEntry(contentHash, currentTime); + _head = (_head + 1) % _ringBuffer.Length; + return true; + } +} diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 10d329003..6ee1f9949 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -15,10 +15,6 @@ internal class UnityApplicationLoggingIntegration : ISdkIntegration private readonly IApplication _application; private readonly bool _captureExceptions; - private ErrorTimeDebounce _errorTimeDebounce = null!; // Set in Register - private LogTimeDebounce _logTimeDebounce = null!; // Set in Register - private WarningTimeDebounce _warningTimeDebounce = null!; // Set in Register - private IHub? _hub; private SentryUnityOptions _options = null!; // Set in Register @@ -34,10 +30,6 @@ public void Register(IHub hub, SentryOptions sentryOptions) // This should never throw _options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'."); - _logTimeDebounce = new LogTimeDebounce(_options.DebounceTimeLog); - _warningTimeDebounce = new WarningTimeDebounce(_options.DebounceTimeWarning); - _errorTimeDebounce = new ErrorTimeDebounce(_options.DebounceTimeError); - _application.LogMessageReceived += OnLogMessageReceived; _application.Quitting += OnQuitting; } @@ -55,7 +47,7 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo return; } - if (IsGettingDebounced(logType)) + if (IsGettingDebounced(message, stacktrace, logType)) { _options.LogDebug("Log message of type '{0}' is getting debounced.", logType); return; @@ -66,21 +58,15 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo ProcessBreadcrumbs(message, logType); } - private bool IsGettingDebounced(LogType logType) + private bool IsGettingDebounced(string message, string stacktrace, LogType logType) { if (_options.EnableLogDebouncing is false) { return false; } - return logType switch - { - LogType.Exception => !_errorTimeDebounce.Debounced(), - LogType.Error or LogType.Assert => !_errorTimeDebounce.Debounced(), - LogType.Log => !_logTimeDebounce.Debounced(), - LogType.Warning => !_warningTimeDebounce.Debounced(), - _ => true - }; + // Use the debouncer from options - returns true if allowed, false if blocked + return !_options.LogDebouncer.Debounced(message, stacktrace, logType); } private void ProcessException(string message, string stacktrace, LogType logType) diff --git a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs index 90f8b9a7f..ed6ceb6fc 100644 --- a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs @@ -106,9 +106,26 @@ private void ProcessLog(LogType logType, UnityEngine.Object? context, string for return; } + // if (IsGettingDebounced(message, stacktrace, logType)) + // { + // _options.LogDebug("Log message of type '{0}' is getting debounced.", logType); + // return; + // } + ProcessStructuredLog(logType, format, args); } + private bool IsGettingDebounced(string message, string stacktrace, LogType logType) + { + if (_options.EnableLogDebouncing is false) + { + return false; + } + + // Use the debouncer from options - returns true if allowed, false if blocked + return !_options.LogDebouncer.Debounced(message, stacktrace, logType); + } + private void ProcessStructuredLog(LogType logType, string format, params object[] args) { if (!_options.Experimental.CaptureStructuredLogsForLogType.TryGetValue(logType, out var captureLog) || !captureLog) diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index 7f1a60f26..616b35490 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -30,9 +30,7 @@ public static string GetConfigPath(string? notDefaultConfigName = null) [field: SerializeField] public bool CaptureInEditor { get; set; } = true; [field: SerializeField] public bool EnableLogDebouncing { get; set; } = false; - [field: SerializeField] public int DebounceTimeLog { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds; - [field: SerializeField] public int DebounceTimeWarning { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds; - [field: SerializeField] public int DebounceTimeError { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds; + [field: SerializeField] public int DebounceTimeWindow { get; set; } = (int)TimeSpan.FromSeconds(1).TotalMilliseconds; [field: SerializeField] public double TracesSampleRate { get; set; } = 0; [field: SerializeField] public bool AutoStartupTraces { get; set; } = true; @@ -149,9 +147,7 @@ internal SentryUnityOptions ToSentryUnityOptions( Dsn = Dsn, CaptureInEditor = CaptureInEditor, EnableLogDebouncing = EnableLogDebouncing, - DebounceTimeLog = TimeSpan.FromMilliseconds(DebounceTimeLog), - DebounceTimeWarning = TimeSpan.FromMilliseconds(DebounceTimeWarning), - DebounceTimeError = TimeSpan.FromMilliseconds(DebounceTimeError), + DebounceTimeWindow = TimeSpan.FromMilliseconds(DebounceTimeWindow), TracesSampleRate = TracesSampleRate, AutoStartupTraces = AutoStartupTraces, AutoSceneLoadTraces = AutoSceneLoadTraces, diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 34385198e..1df11ff58 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -48,24 +48,29 @@ public sealed class SentryUnityOptions : SentryOptions public bool CaptureInEditor { get; set; } = true; /// - /// Whether Sentry events should be debounced it too frequent. + /// Whether Sentry events should be deduplicated if they occur too frequently. + /// When enabled, duplicate log messages with the same content will be suppressed within the debounce window. /// public bool EnableLogDebouncing { get; set; } = false; /// - /// Timespan between sending events of LogType.Log + /// Time window for deduplicating log messages with identical content. + /// If the same log message appears multiple times within this window, only the first occurrence is captured. /// - public TimeSpan DebounceTimeLog { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan DebounceTimeWindow { get; set; } = TimeSpan.FromSeconds(1); - /// - /// Timespan between sending events of LogType.Warning - /// - public TimeSpan DebounceTimeWarning { get; set; } = TimeSpan.FromSeconds(1); + private IUnityLogMessageDebounce? _logDebouncer; /// - /// Timespan between sending events of LogType.Assert, LogType.Exception and LogType.Error + /// The debouncer used for deduplicating log messages. + /// Defaults to ContentDebounce which uses content-based hashing. + /// Can be set to a custom implementation for advanced deduplication logic. /// - public TimeSpan DebounceTimeError { get; set; } = TimeSpan.FromSeconds(1); + public IUnityLogMessageDebounce LogDebouncer + { + get => _logDebouncer ??= new ContentDebounce(DebounceTimeWindow); + set => _logDebouncer = value; + } private CompressionLevelWithAuto _requestBodyCompressionLevel = CompressionLevelWithAuto.Auto; diff --git a/src/Sentry.Unity/TimeDebounceBase.cs b/src/Sentry.Unity/TimeDebounceBase.cs deleted file mode 100644 index ba6a6f42c..000000000 --- a/src/Sentry.Unity/TimeDebounceBase.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; - -namespace Sentry.Unity; - -public interface IUnityLogMessageDebounce -{ - bool Debounced(); -} - -/// -/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback -/// -internal class TimeDebounceBase : IUnityLogMessageDebounce -{ - private static DateTimeOffset Now => DateTimeOffset.UtcNow; - - protected TimeSpan DebounceOffset; - - private DateTimeOffset? _barrierOffset; - - public bool Debounced() - { - if (_barrierOffset != null && Now < _barrierOffset) - { - return false; - } - - _barrierOffset = Now.Add(DebounceOffset); - return true; - } -} - -/// -/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback -/// -internal sealed class LogTimeDebounce : TimeDebounceBase -{ - public LogTimeDebounce(TimeSpan debounceOffset) => DebounceOffset = debounceOffset; -} - -/// -/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback -/// -internal sealed class ErrorTimeDebounce : TimeDebounceBase -{ - public ErrorTimeDebounce(TimeSpan debounceOffset) => DebounceOffset = debounceOffset; -} - -/// -/// This class is not thread-safe and is designed to be called by Unity non-threaded logger callback -/// -internal sealed class WarningTimeDebounce : TimeDebounceBase -{ - public WarningTimeDebounce(TimeSpan debounceOffset) => DebounceOffset = debounceOffset; -} diff --git a/test/Sentry.Unity.Tests/DebouncerTests.cs b/test/Sentry.Unity.Tests/DebouncerTests.cs index 1602e1845..0e974f6dc 100644 --- a/test/Sentry.Unity.Tests/DebouncerTests.cs +++ b/test/Sentry.Unity.Tests/DebouncerTests.cs @@ -7,51 +7,107 @@ namespace Sentry.Unity.Tests; /// -/// Testing debouncer in realtime. +/// Testing content-based debouncer. /// public sealed class DebouncerTests { - private readonly TimeSpan DefaultOffset = TimeSpan.FromMilliseconds(100); + private readonly TimeSpan DefaultWindow = TimeSpan.FromMilliseconds(500); - [UnityTest] - public IEnumerator LogTimeDebounce() + [Test] + public void ContentDebounce_DifferentMessages_AllowsThrough() { - Assert.Inconclusive("Flaky"); // Ignoring because of flakiness: https://github.com/getsentry/sentry-unity/issues/335 - yield return AssertDefaultDebounce(new LogTimeDebounce(DefaultOffset)); + var debouncer = new ContentDebounce(DefaultWindow); + + // First message should pass + Assert.IsTrue(debouncer.Debounced("Error message 1", "Stacktrace 1", LogType.Error)); + + // Different message should pass immediately + Assert.IsTrue(debouncer.Debounced("Error message 2", "Stacktrace 2", LogType.Error)); + + // Another different message should pass + Assert.IsTrue(debouncer.Debounced("Error message 3", "Stacktrace 3", LogType.Error)); } - [UnityTest] - public IEnumerator ErrorTimeDebounce() + [Test] + public void ContentDebounce_SameMessage_BlocksDuplicate() { - Assert.Inconclusive("Flaky"); // Ignoring because of flakiness: https://github.com/getsentry/sentry-unity/issues/335 - yield return AssertDefaultDebounce(new ErrorTimeDebounce(DefaultOffset)); + var debouncer = new ContentDebounce(DefaultWindow); + + // First message should pass + Assert.IsTrue(debouncer.Debounced("Error message", "Stacktrace", LogType.Error)); + + // Same message should be blocked + Assert.IsFalse(debouncer.Debounced("Error message", "Stacktrace", LogType.Error)); + + // Still blocked + Assert.IsFalse(debouncer.Debounced("Error message", "Stacktrace", LogType.Error)); } [UnityTest] - public IEnumerator WarningTimeDebounce() + public IEnumerator ContentDebounce_SameMessage_AllowsAfterWindow() + { + var debouncer = new ContentDebounce(TimeSpan.FromMilliseconds(100)); + + // First message should pass + Assert.IsTrue(debouncer.Debounced("Error message", "Stacktrace", LogType.Error)); + + // Same message immediately blocked + Assert.IsFalse(debouncer.Debounced("Error message", "Stacktrace", LogType.Error)); + + // Wait for debounce window to expire + yield return new WaitForSeconds(0.15f); + + // Same message should now pass + Assert.IsTrue(debouncer.Debounced("Error message", "Stacktrace", LogType.Error)); + } + + [Test] + public void ContentDebounce_DifferentLogTypes_AllowsThrough() { - Assert.Inconclusive("Flaky"); // Ignoring because of flakiness: https://github.com/getsentry/sentry-unity/issues/335 - yield return AssertDefaultDebounce(new WarningTimeDebounce(DefaultOffset)); + var debouncer = new ContentDebounce(DefaultWindow); + + Assert.IsTrue(debouncer.Debounced("Message", "Stack", LogType.Error)); + Assert.IsTrue(debouncer.Debounced("Message", "Stack", LogType.Warning)); + Assert.IsTrue(debouncer.Debounced("Message", "Stack", LogType.Log)); } - private IEnumerator AssertDefaultDebounce(TimeDebounceBase debouncer) + [Test] + public void ContentDebounce_SameMessageDifferentStacktrace_Deduplicates() { - // pass - Assert.IsTrue(debouncer.Debounced()); + var debouncer = new ContentDebounce(DefaultWindow); - yield return new WaitForSeconds(0.050f); + // First occurrence + Assert.IsTrue(debouncer.Debounced("Error occurred", "at Main() line 10", LogType.Error)); - // skip - Assert.IsFalse(debouncer.Debounced()); + // Same message, slightly different stacktrace (but same first line) + Assert.IsFalse(debouncer.Debounced("Error occurred", "at Main() line 10\nat Foo()", LogType.Error)); + } - yield return new WaitForSeconds(0.02f); + [Test] + public void ContentDebounce_DifferentFirstLineOfStacktrace_AllowsThrough() + { + var debouncer = new ContentDebounce(DefaultWindow); + + // First occurrence + Assert.IsTrue(debouncer.Debounced("Error occurred", "at Main() line 10", LogType.Error)); - // skip - Assert.IsFalse(debouncer.Debounced()); + // Same message but different location (different first line of stacktrace) + Assert.IsTrue(debouncer.Debounced("Error occurred", "at Other() line 5", LogType.Error)); + } + + [Test] + public void ContentDebounce_ManyDifferentMessages_HandlesCorrectly() + { + var debouncer = new ContentDebounce(DefaultWindow); - yield return new WaitForSeconds(0.04f); + // Add many different messages + for (int i = 0; i < 100; i++) + { + Assert.IsTrue(debouncer.Debounced($"Error {i}", $"Stack {i}", LogType.Error)); + } - // pass - Assert.IsTrue(debouncer.Debounced()); + // Duplicates of first messages should still be blocked + Assert.IsFalse(debouncer.Debounced("Error 0", "Stack 0", LogType.Error)); + Assert.IsFalse(debouncer.Debounced("Error 50", "Stack 50", LogType.Error)); } } From 8f2cb90bf016da55a3a2649cea743b4597950143 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Thu, 13 Nov 2025 16:13:57 +0000 Subject: [PATCH 20/20] Format code --- src/Sentry.Unity/SentryUnityOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 20514370d..b0038a8a8 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -544,4 +544,4 @@ public enum NativeInitializationType /// game. Options that you modify programmatically will not apply to the native SDK. /// BuildTime, -} \ No newline at end of file +}