From 18e7843b531575bcf2c6733c447c468c648d4304 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 1 May 2026 13:12:02 +1200 Subject: [PATCH 1/5] Add public extension methods for checking unhandled exceptions Adds two new public extension methods to SentryEvent: - IsFromUnhandledException(): Checks if event is from any unhandled exception - IsFromTerminalException(): Checks if event is from a terminal (crash-causing) exception This addresses the need to filter events based on whether they're unhandled/terminal, particularly useful in MAUI apps where users want to always capture terminal exceptions even if they match other filters (e.g., network timeouts). The implementation uses extension methods as suggested by @bruno-garcia, providing a clean public API without exposing internal methods. Both methods check Exception.Data and SentryExceptions for the handled/terminal flags. Fixes #2877 Co-Authored-By: Claude Sonnet 4.5 --- src/Sentry/SentryEventExtensions.cs | 110 +++++++ .../SentryEventExtensionsTests.cs | 287 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/Sentry/SentryEventExtensions.cs create mode 100644 test/Sentry.Tests/SentryEventExtensionsTests.cs diff --git a/src/Sentry/SentryEventExtensions.cs b/src/Sentry/SentryEventExtensions.cs new file mode 100644 index 0000000000..d39c4f8fe7 --- /dev/null +++ b/src/Sentry/SentryEventExtensions.cs @@ -0,0 +1,110 @@ +using Sentry.Protocol; + +namespace Sentry; + +/// +/// Extension methods for . +/// +public static class SentryEventExtensions +{ + /// + /// Determines whether this event was created from an unhandled exception. + /// + /// The Sentry event. + /// + /// true if the event was created from an unhandled exception; otherwise, false. + /// + /// + /// An unhandled exception is one that was not caught by application code and was instead + /// captured by the Sentry SDK through hooks like UnhandledExceptionHandler, ASP.NET Core + /// middleware, or other integration points. By default, Sentry marks exceptions as handled + /// unless explicitly captured through one of these unhandled exception hooks. + /// + /// This is useful for filtering events in callbacks like BeforeSend, where you may want to + /// treat unhandled exceptions differently from handled ones (e.g., always send unhandled + /// exceptions even if they match a filter that would normally exclude them). + /// + /// + /// + /// options.SetBeforeSend((@event, hint) => + /// { + /// // Always send unhandled exceptions, even if they're network timeouts + /// if (@event.IsFromUnhandledException()) + /// { + /// return @event; + /// } + /// + /// // Filter out handled network timeout exceptions + /// if (@event.Exception is HttpRequestException) + /// { + /// return null; + /// } + /// + /// return @event; + /// }); + /// + /// + public static bool IsFromUnhandledException(this SentryEvent @event) + { + // Check if the original exception was marked as unhandled + if (@event.Exception?.Data[Mechanism.HandledKey] is false) + { + return true; + } + + // Check if any of the Sentry exceptions have an unhandled mechanism + return @event.SentryExceptions?.Any(e => e.Mechanism is { Handled: false }) ?? false; + } + + /// + /// Determines whether this event was created from a terminal exception. + /// + /// The Sentry event. + /// + /// true if the event was created from a terminal exception; otherwise, false. + /// + /// + /// A terminal exception is an unhandled exception that caused the application to crash or + /// terminate. This excludes unhandled exceptions that were explicitly marked as non-terminal, + /// such as those captured by UnobservedTaskException handlers or certain Unity SDK integrations. + /// + /// In most cases, an unhandled exception is terminal. However, some integrations may capture + /// unhandled exceptions that don't actually crash the app (e.g., unobserved task exceptions) + /// and mark them with Terminal = false. + /// + /// + /// + /// options.SetBeforeSend((@event, hint) => + /// { + /// // Only send terminal exceptions for certain exception types + /// if (@event.Exception is NetworkException && !@event.IsFromTerminalException()) + /// { + /// return null; // Don't send non-terminal network exceptions + /// } + /// + /// return @event; + /// }); + /// + /// + public static bool IsFromTerminalException(this SentryEvent @event) + { + // Check if the original exception was unhandled and not explicitly marked as non-terminal + if (@event.Exception?.Data[Mechanism.HandledKey] is false) + { + // If it's unhandled but explicitly marked as non-terminal, return false + if (@event.Exception.Data[Mechanism.TerminalKey] is false) + { + return false; + } + // Otherwise, unhandled exceptions are terminal by default + return true; + } + + // Check if any Sentry exceptions are unhandled and terminal + // (handled: false and terminal: not explicitly false) + return @event.SentryExceptions?.Any(e => + e.Mechanism is { Handled: false } && + e.Mechanism.Terminal != false + ) ?? false; + } +} diff --git a/test/Sentry.Tests/SentryEventExtensionsTests.cs b/test/Sentry.Tests/SentryEventExtensionsTests.cs new file mode 100644 index 0000000000..cbd676daac --- /dev/null +++ b/test/Sentry.Tests/SentryEventExtensionsTests.cs @@ -0,0 +1,287 @@ +namespace Sentry.Tests; + +public class SentryEventExtensionsTests +{ + [Fact] + public void IsFromUnhandledException_NoException_ReturnsFalse() + { + // Arrange + var sentryEvent = new SentryEvent(); + + // Act + var result = sentryEvent.IsFromUnhandledException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromUnhandledException_HandledExceptionInExceptionProperty_ReturnsFalse() + { + // Arrange + var exception = new Exception("test"); + exception.Data[Mechanism.HandledKey] = true; + var sentryEvent = new SentryEvent(exception); + + // Act + var result = sentryEvent.IsFromUnhandledException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromUnhandledException_UnhandledExceptionInExceptionProperty_ReturnsTrue() + { + // Arrange + var exception = new Exception("test"); + exception.Data[Mechanism.HandledKey] = false; + var sentryEvent = new SentryEvent(exception); + + // Act + var result = sentryEvent.IsFromUnhandledException(); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsFromUnhandledException_HandledExceptionInSentryExceptions_ReturnsFalse() + { + // Arrange + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "test", + Mechanism = new Mechanism { Handled = true } + } + } + }; + + // Act + var result = sentryEvent.IsFromUnhandledException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromUnhandledException_UnhandledExceptionInSentryExceptions_ReturnsTrue() + { + // Arrange + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "test", + Mechanism = new Mechanism { Handled = false } + } + } + }; + + // Act + var result = sentryEvent.IsFromUnhandledException(); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsFromUnhandledException_MixedExceptions_ReturnsTrue() + { + // Arrange - if ANY exception is unhandled, returns true + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "handled", + Mechanism = new Mechanism { Handled = true } + }, + new SentryException + { + Type = "Exception", + Value = "unhandled", + Mechanism = new Mechanism { Handled = false } + } + } + }; + + // Act + var result = sentryEvent.IsFromUnhandledException(); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsFromTerminalException_NoException_ReturnsFalse() + { + // Arrange + var sentryEvent = new SentryEvent(); + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromTerminalException_HandledExceptionInExceptionProperty_ReturnsFalse() + { + // Arrange + var exception = new Exception("test"); + exception.Data[Mechanism.HandledKey] = true; + var sentryEvent = new SentryEvent(exception); + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromTerminalException_UnhandledTerminalExceptionInExceptionProperty_ReturnsTrue() + { + // Arrange + var exception = new Exception("test"); + exception.Data[Mechanism.HandledKey] = false; + // Terminal is true by default when unhandled + var sentryEvent = new SentryEvent(exception); + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsFromTerminalException_UnhandledNonTerminalExceptionInExceptionProperty_ReturnsFalse() + { + // Arrange + var exception = new Exception("test"); + exception.Data[Mechanism.HandledKey] = false; + exception.Data[Mechanism.TerminalKey] = false; // Explicitly non-terminal + var sentryEvent = new SentryEvent(exception); + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromTerminalException_UnhandledTerminalExceptionInSentryExceptions_ReturnsTrue() + { + // Arrange + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "test", + Mechanism = new Mechanism { Handled = false } // Terminal is null, defaults to true + } + } + }; + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsFromTerminalException_UnhandledExplicitlyTerminalExceptionInSentryExceptions_ReturnsTrue() + { + // Arrange + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "test", + Mechanism = new Mechanism { Handled = false, Terminal = true } + } + } + }; + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsFromTerminalException_UnhandledNonTerminalExceptionInSentryExceptions_ReturnsFalse() + { + // Arrange + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "test", + Mechanism = new Mechanism { Handled = false, Terminal = false } + } + } + }; + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsFromTerminalException_MixedExceptions_ReturnsTrueIfAnyTerminal() + { + // Arrange + var sentryEvent = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Type = "Exception", + Value = "non-terminal", + Mechanism = new Mechanism { Handled = false, Terminal = false } + }, + new SentryException + { + Type = "Exception", + Value = "terminal", + Mechanism = new Mechanism { Handled = false, Terminal = true } + } + } + }; + + // Act + var result = sentryEvent.IsFromTerminalException(); + + // Assert + Assert.True(result); + } +} From ad784d80f38e05912ddd80dfbc8b77dba3b9b6a1 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 4 May 2026 14:16:46 +1200 Subject: [PATCH 2/5] Verify tests --- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 5 +++++ .../Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt | 5 +++++ .../Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index ccf866768e..3bc8379af9 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -552,6 +552,11 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } + public static class SentryEventExtensions + { + public static bool IsFromTerminalException(this Sentry.SentryEvent @event) { } + public static bool IsFromUnhandledException(this Sentry.SentryEvent @event) { } + } public sealed class SentryFeedback : Sentry.ISentryJsonSerializable { public SentryFeedback(string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index ccf866768e..3bc8379af9 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -552,6 +552,11 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } + public static class SentryEventExtensions + { + public static bool IsFromTerminalException(this Sentry.SentryEvent @event) { } + public static bool IsFromUnhandledException(this Sentry.SentryEvent @event) { } + } public sealed class SentryFeedback : Sentry.ISentryJsonSerializable { public SentryFeedback(string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index ccf866768e..3bc8379af9 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -552,6 +552,11 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } + public static class SentryEventExtensions + { + public static bool IsFromTerminalException(this Sentry.SentryEvent @event) { } + public static bool IsFromUnhandledException(this Sentry.SentryEvent @event) { } + } public sealed class SentryFeedback : Sentry.ISentryJsonSerializable { public SentryFeedback(string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default) { } From 220411f517b2d96611463b455b359459a7d84e8c Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 4 May 2026 14:42:18 +1200 Subject: [PATCH 3/5] windows verify --- test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 1fc1e68f4b..040a3802bd 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -540,6 +540,11 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } + public static class SentryEventExtensions + { + public static bool IsFromTerminalException(this Sentry.SentryEvent @event) { } + public static bool IsFromUnhandledException(this Sentry.SentryEvent @event) { } + } public sealed class SentryFeedback : Sentry.ISentryJsonSerializable { public SentryFeedback(string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default) { } From 8fc8368ea4ef3d882b966e1dffd21d2e80253897 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 6 May 2026 18:09:08 +1200 Subject: [PATCH 4/5] Remove redundant private methods in SentryEvent, delegate to extension methods HasUnhandledException() and HasUnhandledNonTerminalException() duplicated the logic in the new public extension methods. Removed the private methods and updated GetExceptionType() to call IsFromUnhandledException() and IsFromTerminalException() directly. Co-Authored-By: Claude Sonnet 4.6 --- src/Sentry/SentryEvent.cs | 35 ++--------------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 3a84fa3969..c6f8f8d50b 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -193,12 +193,12 @@ internal ExceptionType GetExceptionType() return ExceptionType.None; } - if (HasUnhandledNonTerminalException()) + if (this.IsFromUnhandledException() && !this.IsFromTerminalException()) { return ExceptionType.UnhandledNonTerminal; } - if (HasUnhandledException()) + if (this.IsFromUnhandledException()) { return ExceptionType.UnhandledTerminal; } @@ -208,37 +208,6 @@ internal ExceptionType GetExceptionType() private bool HasException() => Exception is not null || SentryExceptions?.Any() == true; - private bool HasUnhandledException() - { - if (Exception?.Data[Mechanism.HandledKey] is false) - { - return true; - } - - return SentryExceptions?.Any(e => e.Mechanism is { Handled: false }) ?? false; - } - - private bool HasUnhandledNonTerminalException() - { - // Generally, an unhandled exception is considered terminal. - // Exception: If it is an unhandled exception but the terminal flag is explicitly set to false. - // I.e. captured through the UnobservedTaskExceptionIntegration, or the exception capture integrations in the Unity SDK - - if (Exception?.Data[Mechanism.HandledKey] is false) - { - if (Exception.Data[Mechanism.TerminalKey] is false) - { - return true; - } - - return false; - } - - return SentryExceptions?.Any(e => - e.Mechanism is { Handled: false, Terminal: false } - ) ?? false; - } - internal DynamicSamplingContext? DynamicSamplingContext { get; set; } /// From b984ba29315c1288ed8c4bcaa544cc2387c89787 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 7 May 2026 12:05:30 +1200 Subject: [PATCH 5/5] Restore HasUnhandledNonTerminalException(), fix semantic bug IsFromUnhandledException() && !IsFromTerminalException() is not equivalent to the original per-exception AND check. When SentryExceptions contains a mix of terminal and non-terminal unhandled exceptions, IsFromTerminalException() returns true (finding the terminal one), incorrectly suppressing the UnhandledNonTerminal result. Restore the private method with its original body. HasUnhandledException() remains replaced by the public IsFromUnhandledException() extension method, which is a true semantic equivalent. Co-Authored-By: Claude Sonnet 4.6 --- src/Sentry/SentryEvent.cs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index c6f8f8d50b..09d16fbad3 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -193,7 +193,7 @@ internal ExceptionType GetExceptionType() return ExceptionType.None; } - if (this.IsFromUnhandledException() && !this.IsFromTerminalException()) + if (HasUnhandledNonTerminalException()) { return ExceptionType.UnhandledNonTerminal; } @@ -208,6 +208,27 @@ internal ExceptionType GetExceptionType() private bool HasException() => Exception is not null || SentryExceptions?.Any() == true; + private bool HasUnhandledNonTerminalException() + { + // Generally, an unhandled exception is considered terminal. + // Exception: If it is an unhandled exception but the terminal flag is explicitly set to false. + // I.e. captured through the UnobservedTaskExceptionIntegration, or the exception capture integrations in the Unity SDK + + if (Exception?.Data[Mechanism.HandledKey] is false) + { + if (Exception.Data[Mechanism.TerminalKey] is false) + { + return true; + } + + return false; + } + + return SentryExceptions?.Any(e => + e.Mechanism is { Handled: false, Terminal: false } + ) ?? false; + } + internal DynamicSamplingContext? DynamicSamplingContext { get; set; } ///