From 0540a7886640742eefb04aa18994240437ca99cb Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sat, 25 Jul 2026 22:38:39 +0200 Subject: [PATCH 1/7] feat: Allow users to control Mechanism.Handled for captured exceptions (#3383) Co-Authored-By: Claude Opus 5 (1M context) --- src/Sentry/HubExtensions.cs | 52 +++++++- src/Sentry/SentryClientExtensions.cs | 39 +++++- src/Sentry/SentrySdk.cs | 32 +++++ ...sts.RecordsEfAsync.DotNet10_0.verified.txt | 1 + ...ests.RecordsEfAsync.DotNet8_0.verified.txt | 1 + ...ests.RecordsEfAsync.DotNet9_0.verified.txt | 1 + ...erTests.RecordsEfAsync.Net4_8.verified.txt | 1 + ...ListenerTests.RecordsSqlAsync.verified.txt | 1 + .../IntegrationTests.Simple.verified.txt | 1 + ...iApprovalTests.Run.DotNet10_0.verified.txt | 4 + ...piApprovalTests.Run.DotNet8_0.verified.txt | 4 + ...piApprovalTests.Run.DotNet9_0.verified.txt | 4 + .../ApiApprovalTests.Run.Net4_8.verified.txt | 4 + test/Sentry.Tests/HubExtensionsTests.cs | 121 ++++++++++++++++++ .../SentryClientExtensionsTests.cs | 92 +++++++++++++ 15 files changed, 352 insertions(+), 6 deletions(-) diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 061378afb5..03b190c9bb 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -2,6 +2,7 @@ using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Internal.Extensions; +using Sentry.Protocol; namespace Sentry; @@ -268,18 +269,61 @@ public LockedScope(IHub hub) public void Dispose() => _scope.Dispose(); } - internal static SentryId CaptureExceptionInternal(this IHub hub, Exception ex) => - hub.CaptureEvent(new SentryEvent(ex)); + internal static SentryId CaptureExceptionInternal(this IHub hub, Exception ex) + { + // Integrations capture exceptions that were not handled by user code, so default the + // mechanism to handled: false, unless the integration has already set the flag explicitly. + if (ex.Data[Mechanism.HandledKey] is not bool) + { + ex.Data[Mechanism.HandledKey] = false; + } + return hub.CaptureEvent(new SentryEvent(ex)); + } /// /// Captures the exception with a configurable scope callback. /// + /// + /// The exception is reported as handled, unless a flag was already set on it, + /// e.g. via . + /// /// The Sentry hub. /// The exception. /// The callback to configure the scope. /// The Id of the event - public static SentryId CaptureException(this IHub hub, Exception ex, Action configureScope) => - hub.CaptureEvent(new SentryEvent(ex), configureScope); + public static SentryId CaptureException(this IHub hub, Exception ex, Action configureScope) + { + if (!hub.IsEnabled) + { + return SentryId.Empty; + } + + if (ex.Data[Mechanism.HandledKey] is not bool) + { + ex.Data[Mechanism.HandledKey] = true; + } + return hub.CaptureEvent(new SentryEvent(ex), configureScope); + } + + /// + /// Captures the exception with a configurable scope callback, explicitly marking it as handled or unhandled. + /// + /// The Sentry hub. + /// The exception. + /// The callback to configure the scope. + /// Whether the exception was handled. Recorded on the exception, overriding any flag + /// previously set on it, including one set via . + /// The Id of the event + public static SentryId CaptureException(this IHub hub, Exception ex, Action configureScope, bool handled) + { + if (!hub.IsEnabled) + { + return SentryId.Empty; + } + + ex.Data[Mechanism.HandledKey] = handled; + return hub.CaptureEvent(new SentryEvent(ex), configureScope); + } /// /// Captures feedback from the user. diff --git a/src/Sentry/SentryClientExtensions.cs b/src/Sentry/SentryClientExtensions.cs index abcf1dc099..6257bb1c99 100644 --- a/src/Sentry/SentryClientExtensions.cs +++ b/src/Sentry/SentryClientExtensions.cs @@ -1,5 +1,6 @@ using Sentry.Extensibility; using Sentry.Internal; +using Sentry.Protocol; namespace Sentry; @@ -12,11 +13,45 @@ public static class SentryClientExtensions /// /// Captures the exception. /// + /// + /// The exception is reported as handled, unless a flag was already set on it, + /// e.g. via . + /// + /// The Sentry client. + /// The exception. + /// The Id of the event + public static SentryId CaptureException(this ISentryClient client, Exception ex) + { + if (!client.IsEnabled) + { + return SentryId.Empty; + } + + if (ex.Data[Mechanism.HandledKey] is not bool) + { + ex.Data[Mechanism.HandledKey] = true; + } + return client.CaptureEvent(new SentryEvent(ex)); + } + + /// + /// Captures the exception, explicitly marking it as handled or unhandled. + /// /// The Sentry client. /// The exception. + /// Whether the exception was handled. Recorded on the exception, overriding any flag + /// previously set on it, including one set via . /// The Id of the event - public static SentryId CaptureException(this ISentryClient client, Exception ex) => - client.IsEnabled ? client.CaptureEvent(new SentryEvent(ex)) : SentryId.Empty; + public static SentryId CaptureException(this ISentryClient client, Exception ex, bool handled) + { + if (!client.IsEnabled) + { + return SentryId.Empty; + } + + ex.Data[Mechanism.HandledKey] = handled; + return client.CaptureEvent(new SentryEvent(ex)); + } /// /// Captures a message. diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index fcc557ec75..8d4a4c47b5 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -518,17 +518,34 @@ public static SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Action /// Captures the exception. /// + /// + /// The exception is reported as handled, unless a flag was already set on it, + /// e.g. via . + /// /// The exception. /// The Id of the event. [DebuggerStepThrough] public static SentryId CaptureException(Exception exception) => CurrentHub.CaptureException(exception); + /// + /// Captures the exception, explicitly marking it as handled or unhandled. + /// + /// The exception. + /// Whether the exception was handled. Recorded on the exception, overriding any flag + /// previously set on it, including one set via . + /// The Id of the event. + [DebuggerStepThrough] + public static SentryId CaptureException(Exception exception, bool handled) + => CurrentHub.CaptureException(exception, handled); + /// /// Captures the exception with a configurable scope. /// /// /// This allows modifying a scope without affecting other events. + /// The exception is reported as handled, unless a flag was already set on it, + /// e.g. via . /// /// The exception. /// The callback to configure the scope. @@ -537,6 +554,21 @@ public static SentryId CaptureException(Exception exception) public static SentryId CaptureException(Exception exception, Action configureScope) => CurrentHub.CaptureException(exception, configureScope); + /// + /// Captures the exception with a configurable scope, explicitly marking it as handled or unhandled. + /// + /// + /// This allows modifying a scope without affecting other events. + /// + /// The exception. + /// The callback to configure the scope. + /// Whether the exception was handled. Recorded on the exception, overriding any flag + /// previously set on it, including one set via . + /// The Id of the event. + [DebuggerStepThrough] + public static SentryId CaptureException(Exception exception, Action configureScope, bool handled) + => CurrentHub.CaptureException(exception, configureScope, handled); + /// /// Captures the message. /// diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt index 290207f39e..3c95d6b346 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt index 290207f39e..3c95d6b346 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt index 290207f39e..3c95d6b346 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt index 51fc328f6a..96a1009270 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt index fbf3184173..b86c6fec54 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt b/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt index 42af21ba1b..b807582bf8 100644 --- a/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt +++ b/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index b9fb58dba4..d796c918f3 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -148,6 +148,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } @@ -491,6 +492,7 @@ namespace Sentry public static class SentryClientExtensions { public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex) { } + public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.ISentryClient client, string message, Sentry.SentryLevel level = 1) { } @@ -978,6 +980,8 @@ namespace Sentry public static Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope) { } public static Sentry.SentryId CaptureException(System.Exception exception) { } public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(System.Exception exception, bool handled) { } + public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, out Sentry.CaptureFeedbackResult result, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index b9fb58dba4..d796c918f3 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -148,6 +148,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } @@ -491,6 +492,7 @@ namespace Sentry public static class SentryClientExtensions { public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex) { } + public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.ISentryClient client, string message, Sentry.SentryLevel level = 1) { } @@ -978,6 +980,8 @@ namespace Sentry public static Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope) { } public static Sentry.SentryId CaptureException(System.Exception exception) { } public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(System.Exception exception, bool handled) { } + public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, out Sentry.CaptureFeedbackResult result, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index b9fb58dba4..d796c918f3 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -148,6 +148,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } @@ -491,6 +492,7 @@ namespace Sentry public static class SentryClientExtensions { public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex) { } + public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.ISentryClient client, string message, Sentry.SentryLevel level = 1) { } @@ -978,6 +980,8 @@ namespace Sentry public static Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope) { } public static Sentry.SentryId CaptureException(System.Exception exception) { } public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(System.Exception exception, bool handled) { } + public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, out Sentry.CaptureFeedbackResult result, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 2e103ee02d..8aad111eea 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -136,6 +136,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { } public static void LockScope(this Sentry.IHub hub) { } @@ -479,6 +480,7 @@ namespace Sentry public static class SentryClientExtensions { public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex) { } + public static Sentry.SentryId CaptureException(this Sentry.ISentryClient client, System.Exception ex, bool handled) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(this Sentry.ISentryClient client, string message, string? contactEmail = null, string? name = null, string? replayId = null, string? url = null, Sentry.SentryId? associatedEventId = default, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureMessage(this Sentry.ISentryClient client, string message, Sentry.SentryLevel level = 1) { } @@ -959,6 +961,8 @@ namespace Sentry public static Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope) { } public static Sentry.SentryId CaptureException(System.Exception exception) { } public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope) { } + public static Sentry.SentryId CaptureException(System.Exception exception, bool handled) { } + public static Sentry.SentryId CaptureException(System.Exception exception, System.Action configureScope, bool handled) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } public static Sentry.SentryId CaptureFeedback(Sentry.SentryFeedback feedback, out Sentry.CaptureFeedbackResult result, Sentry.Scope? scope = null, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/HubExtensionsTests.cs b/test/Sentry.Tests/HubExtensionsTests.cs index 83295c2134..e5da60bb3b 100644 --- a/test/Sentry.Tests/HubExtensionsTests.cs +++ b/test/Sentry.Tests/HubExtensionsTests.cs @@ -56,6 +56,127 @@ public void UnlockScope_UnlocksScope() Assert.False(Scope.Locked); } + [Fact] + public void CaptureException_ScopeCallback_NoHandledArgument_DefaultsToHandledTrue() + { + // Arrange + _ = Sut.IsEnabled.Returns(true); + var ex = new Exception(); + + // Act + _ = Sut.CaptureException(ex, _ => { }); + + // Assert + Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_ScopeCallback_NoHandledArgument_PresetFlagIsPreserved() + { + // Arrange + _ = Sut.IsEnabled.Returns(true); + var ex = new Exception(); + ex.SetSentryMechanism("SomeMechanism", handled: false); + + // Act + _ = Sut.CaptureException(ex, _ => { }); + + // Assert + Assert.Equal(false, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_ScopeCallback_DisabledHub_NoHandledArgument_DoesNotMutateException() + { + // Arrange + _ = Sut.IsEnabled.Returns(false); + var ex = new Exception(); + + // Act + var id = Sut.CaptureException(ex, _ => { }); + + // Assert + _ = Sut.DidNotReceive().CaptureEvent(Arg.Any(), Arg.Any>()); + Assert.Equal(default, id); + Assert.False(ex.Data.Contains(Mechanism.HandledKey)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CaptureException_ScopeCallback_ExplicitHandled_RecordsFlagOnException(bool handled) + { + // Arrange + _ = Sut.IsEnabled.Returns(true); + var ex = new Exception(); + + // Act + _ = Sut.CaptureException(ex, _ => { }, handled); + + // Assert + Assert.Equal(handled, ex.Data[Mechanism.HandledKey]); + _ = Sut.Received(1).CaptureEvent(Arg.Any(), Arg.Any>()); + } + + [Fact] + public void CaptureException_ScopeCallback_ExplicitHandled_OverridesFlagSetBySetSentryMechanism() + { + // Arrange + _ = Sut.IsEnabled.Returns(true); + var ex = new Exception(); + ex.SetSentryMechanism("SomeMechanism", handled: true); + + // Act + _ = Sut.CaptureException(ex, _ => { }, handled: false); + + // Assert + Assert.Equal(false, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_ScopeCallback_DisabledHub_ExplicitHandled_DoesNotMutateException() + { + // Arrange + _ = Sut.IsEnabled.Returns(false); + var ex = new Exception(); + + // Act + var id = Sut.CaptureException(ex, _ => { }, handled: false); + + // Assert + _ = Sut.DidNotReceive().CaptureEvent(Arg.Any(), Arg.Any>()); + Assert.Equal(default, id); + Assert.False(ex.Data.Contains(Mechanism.HandledKey)); + } + + [Fact] + public void CaptureExceptionInternal_NoPresetFlag_DefaultsToUnhandled() + { + // Arrange + var ex = new Exception(); + + // Act + _ = Sut.CaptureExceptionInternal(ex); + + // Assert + Assert.Equal(false, ex.Data[Mechanism.HandledKey]); + _ = Sut.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void CaptureExceptionInternal_PresetFlag_IsPreserved() + { + // Arrange + var ex = new Exception(); + ex.SetSentryMechanism("SomeMechanism", handled: true); + + // Act + _ = Sut.CaptureExceptionInternal(ex); + + // Assert + Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + } + [Fact] public void AddBreadcrumb_MinimalArguments_CreatesBreadcrumb() { diff --git a/test/Sentry.Tests/SentryClientExtensionsTests.cs b/test/Sentry.Tests/SentryClientExtensionsTests.cs index 637e05078f..71b215863e 100644 --- a/test/Sentry.Tests/SentryClientExtensionsTests.cs +++ b/test/Sentry.Tests/SentryClientExtensionsTests.cs @@ -22,6 +22,98 @@ public void CaptureException_EnabledClient_CapturesEvent() _ = _sut.Received(1).CaptureEvent(Arg.Any()); } + [Fact] + public void CaptureException_NoHandledArgument_DefaultsToHandledTrue() + { + // Arrange + _ = _sut.IsEnabled.Returns(true); + var ex = new Exception(); + + // Act + _ = _sut.CaptureException(ex); + + // Assert + Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_NoHandledArgument_PresetFlagIsPreserved() + { + // Arrange + _ = _sut.IsEnabled.Returns(true); + var ex = new Exception(); + ex.SetSentryMechanism("SomeMechanism", handled: false); + + // Act + _ = _sut.CaptureException(ex); + + // Assert + Assert.Equal(false, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_DisabledClient_NoHandledArgument_DoesNotMutateException() + { + // Arrange + _ = _sut.IsEnabled.Returns(false); + var ex = new Exception(); + + // Act + var id = _sut.CaptureException(ex); + + // Assert + _ = _sut.DidNotReceive().CaptureEvent(Arg.Any()); + Assert.Equal(default, id); + Assert.False(ex.Data.Contains(Mechanism.HandledKey)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CaptureException_ExplicitHandled_RecordsFlagOnException(bool handled) + { + // Arrange + _ = _sut.IsEnabled.Returns(true); + var ex = new Exception(); + + // Act + _ = _sut.CaptureException(ex, handled); + + // Assert + Assert.Equal(handled, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_ExplicitHandled_OverridesFlagSetBySetSentryMechanism() + { + // Arrange + _ = _sut.IsEnabled.Returns(true); + var ex = new Exception(); + ex.SetSentryMechanism("SomeMechanism", handled: false); + + // Act + _ = _sut.CaptureException(ex, handled: true); + + // Assert + Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + } + + [Fact] + public void CaptureException_DisabledClient_ExplicitHandled_DoesNotMutateException() + { + // Arrange + _ = _sut.IsEnabled.Returns(false); + var ex = new Exception(); + + // Act + var id = _sut.CaptureException(ex, handled: false); + + // Assert + _ = _sut.DidNotReceive().CaptureEvent(Arg.Any()); + Assert.Equal(default, id); + Assert.False(ex.Data.Contains(Mechanism.HandledKey)); + } + [Fact] public void CaptureMessage_DisabledClient_DoesNotCaptureEvent() { From 40b0bec7758d73250088d4b7630d49d01d779570 Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sun, 26 Jul 2026 08:33:11 +0200 Subject: [PATCH 2/7] test: cover explicit handled parameter on SentrySdk.CaptureException Adds SentrySdk-level coverage for the handled parameter, including that an explicit argument overrides a flag previously set via SetSentryMechanism. Co-Authored-By: Claude Fable 5 --- test/Sentry.Tests/SentrySdkTests.cs | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/Sentry.Tests/SentrySdkTests.cs b/test/Sentry.Tests/SentrySdkTests.cs index 45750ebd93..2196636133 100644 --- a/test/Sentry.Tests/SentrySdkTests.cs +++ b/test/Sentry.Tests/SentrySdkTests.cs @@ -672,6 +672,83 @@ public void CaptureException_WithConfiguredScope_ScopeCallbackGetsInvoked() Assert.True(scopeCallbackWasInvoked); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CaptureException_ExplicitHandled_SetsMechanismHandledOnEvent(bool handled) + { + SentryEvent captured = null; + using var _ = SentrySdk.Init(o => + { + o.Dsn = ValidDsn; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + o.SetBeforeSend(e => + { + captured = e; + return e; + }); + }); + + SentrySdk.CaptureException(new Exception("test"), handled); + + Assert.NotNull(captured); + Assert.Equal(handled, Assert.Single(captured.SentryExceptions!).Mechanism!.Handled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CaptureException_WithConfiguredScope_ExplicitHandled_SetsMechanismHandledOnEvent(bool handled) + { + SentryEvent captured = null; + using var _ = SentrySdk.Init(o => + { + o.Dsn = ValidDsn; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + o.SetBeforeSend(e => + { + captured = e; + return e; + }); + }); + + SentrySdk.CaptureException(new Exception("test"), s => s.SetTag("scope-callback", "ran"), handled); + + Assert.NotNull(captured); + Assert.Equal(handled, Assert.Single(captured.SentryExceptions!).Mechanism!.Handled); + Assert.Equal("ran", captured.Tags["scope-callback"]); + } + + [Fact] + public void CaptureException_ExplicitHandled_OverridesFlagSetBySetSentryMechanism() + { + SentryEvent captured = null; + using var _ = SentrySdk.Init(o => + { + o.Dsn = ValidDsn; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + o.SetBeforeSend(e => + { + captured = e; + return e; + }); + }); + + var ex = new Exception("test"); + ex.SetSentryMechanism("SomeMechanism", handled: false); + + SentrySdk.CaptureException(ex, handled: true); + + Assert.NotNull(captured); + Assert.True(Assert.Single(captured.SentryExceptions!).Mechanism!.Handled); + } + [Fact] public void CaptureMessage_WithConfiguredScope_ScopeCallbackGetsInvoked() { From 4a0037a73ab486b8cb1c5fae07af7fd487a8d566 Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sun, 26 Jul 2026 10:03:48 +0200 Subject: [PATCH 3/7] refactor: only the explicit handled overloads touch Mechanism.Handled The non-handled CaptureException overloads no longer default or preserve a handled flag - they behave exactly as before this feature. Only the new overloads taking an explicit `handled` argument write Mechanism.HandledKey, and CaptureExceptionInternal keeps forcing handled: false for integrations. Tests and the DiagnosticSource/EntityFramework snapshots are updated to match. Co-Authored-By: Claude Opus 5 (1M context) --- src/Sentry/HubExtensions.cs | 31 ++------------ src/Sentry/SentryClientExtensions.cs | 19 +-------- src/Sentry/SentrySdk.cs | 6 --- ...sts.RecordsEfAsync.DotNet10_0.verified.txt | 1 - ...ests.RecordsEfAsync.DotNet8_0.verified.txt | 1 - ...ests.RecordsEfAsync.DotNet9_0.verified.txt | 1 - ...erTests.RecordsEfAsync.Net4_8.verified.txt | 1 - ...ListenerTests.RecordsSqlAsync.verified.txt | 1 - .../IntegrationTests.Simple.verified.txt | 1 - test/Sentry.Tests/HubExtensionsTests.cs | 41 +++---------------- .../SentryClientExtensionsTests.cs | 4 +- test/Sentry.Tests/SentrySdkTests.cs | 23 +++++++++++ 12 files changed, 35 insertions(+), 95 deletions(-) diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 03b190c9bb..fbf47b6b35 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -271,39 +271,19 @@ public LockedScope(IHub hub) internal static SentryId CaptureExceptionInternal(this IHub hub, Exception ex) { - // Integrations capture exceptions that were not handled by user code, so default the - // mechanism to handled: false, unless the integration has already set the flag explicitly. - if (ex.Data[Mechanism.HandledKey] is not bool) - { - ex.Data[Mechanism.HandledKey] = false; - } + ex.Data[Mechanism.HandledKey] = false; return hub.CaptureEvent(new SentryEvent(ex)); } /// /// Captures the exception with a configurable scope callback. /// - /// - /// The exception is reported as handled, unless a flag was already set on it, - /// e.g. via . - /// /// The Sentry hub. /// The exception. /// The callback to configure the scope. /// The Id of the event - public static SentryId CaptureException(this IHub hub, Exception ex, Action configureScope) - { - if (!hub.IsEnabled) - { - return SentryId.Empty; - } - - if (ex.Data[Mechanism.HandledKey] is not bool) - { - ex.Data[Mechanism.HandledKey] = true; - } - return hub.CaptureEvent(new SentryEvent(ex), configureScope); - } + public static SentryId CaptureException(this IHub hub, Exception ex, Action configureScope) => + hub.CaptureEvent(new SentryEvent(ex), configureScope); /// /// Captures the exception with a configurable scope callback, explicitly marking it as handled or unhandled. @@ -316,11 +296,6 @@ public static SentryId CaptureException(this IHub hub, Exception ex, ActionThe Id of the event public static SentryId CaptureException(this IHub hub, Exception ex, Action configureScope, bool handled) { - if (!hub.IsEnabled) - { - return SentryId.Empty; - } - ex.Data[Mechanism.HandledKey] = handled; return hub.CaptureEvent(new SentryEvent(ex), configureScope); } diff --git a/src/Sentry/SentryClientExtensions.cs b/src/Sentry/SentryClientExtensions.cs index 6257bb1c99..61f123251e 100644 --- a/src/Sentry/SentryClientExtensions.cs +++ b/src/Sentry/SentryClientExtensions.cs @@ -13,26 +13,11 @@ public static class SentryClientExtensions /// /// Captures the exception. /// - /// - /// The exception is reported as handled, unless a flag was already set on it, - /// e.g. via . - /// /// The Sentry client. /// The exception. /// The Id of the event - public static SentryId CaptureException(this ISentryClient client, Exception ex) - { - if (!client.IsEnabled) - { - return SentryId.Empty; - } - - if (ex.Data[Mechanism.HandledKey] is not bool) - { - ex.Data[Mechanism.HandledKey] = true; - } - return client.CaptureEvent(new SentryEvent(ex)); - } + public static SentryId CaptureException(this ISentryClient client, Exception ex) => + client.IsEnabled ? client.CaptureEvent(new SentryEvent(ex)) : SentryId.Empty; /// /// Captures the exception, explicitly marking it as handled or unhandled. diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 8d4a4c47b5..c4d87f8422 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -518,10 +518,6 @@ public static SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Action /// Captures the exception. /// - /// - /// The exception is reported as handled, unless a flag was already set on it, - /// e.g. via . - /// /// The exception. /// The Id of the event. [DebuggerStepThrough] @@ -544,8 +540,6 @@ public static SentryId CaptureException(Exception exception, bool handled) /// /// /// This allows modifying a scope without affecting other events. - /// The exception is reported as handled, unless a flag was already set on it, - /// e.g. via . /// /// The exception. /// The callback to configure the scope. diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt index 3c95d6b346..290207f39e 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt @@ -13,7 +13,6 @@ Value: my exception, Mechanism: { Type: generic, - Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt index 3c95d6b346..290207f39e 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt @@ -13,7 +13,6 @@ Value: my exception, Mechanism: { Type: generic, - Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt index 3c95d6b346..290207f39e 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt @@ -13,7 +13,6 @@ Value: my exception, Mechanism: { Type: generic, - Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt index 96a1009270..51fc328f6a 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt @@ -13,7 +13,6 @@ Value: my exception, Mechanism: { Type: generic, - Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt index b86c6fec54..fbf3184173 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt @@ -13,7 +13,6 @@ Value: my exception, Mechanism: { Type: generic, - Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt b/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt index b807582bf8..42af21ba1b 100644 --- a/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt +++ b/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt @@ -13,7 +13,6 @@ Value: my exception, Mechanism: { Type: generic, - Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.Tests/HubExtensionsTests.cs b/test/Sentry.Tests/HubExtensionsTests.cs index e5da60bb3b..739f87b1bd 100644 --- a/test/Sentry.Tests/HubExtensionsTests.cs +++ b/test/Sentry.Tests/HubExtensionsTests.cs @@ -57,7 +57,7 @@ public void UnlockScope_UnlocksScope() } [Fact] - public void CaptureException_ScopeCallback_NoHandledArgument_DefaultsToHandledTrue() + public void CaptureException_ScopeCallback_NoHandledArgument_DoesNotSetHandledFlag() { // Arrange _ = Sut.IsEnabled.Returns(true); @@ -67,7 +67,8 @@ public void CaptureException_ScopeCallback_NoHandledArgument_DefaultsToHandledTr _ = Sut.CaptureException(ex, _ => { }); // Assert - Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + Assert.False(ex.Data.Contains(Mechanism.HandledKey)); + _ = Sut.Received(1).CaptureEvent(Arg.Any(), Arg.Any>()); } [Fact] @@ -85,22 +86,6 @@ public void CaptureException_ScopeCallback_NoHandledArgument_PresetFlagIsPreserv Assert.Equal(false, ex.Data[Mechanism.HandledKey]); } - [Fact] - public void CaptureException_ScopeCallback_DisabledHub_NoHandledArgument_DoesNotMutateException() - { - // Arrange - _ = Sut.IsEnabled.Returns(false); - var ex = new Exception(); - - // Act - var id = Sut.CaptureException(ex, _ => { }); - - // Assert - _ = Sut.DidNotReceive().CaptureEvent(Arg.Any(), Arg.Any>()); - Assert.Equal(default, id); - Assert.False(ex.Data.Contains(Mechanism.HandledKey)); - } - [Theory] [InlineData(true)] [InlineData(false)] @@ -133,22 +118,6 @@ public void CaptureException_ScopeCallback_ExplicitHandled_OverridesFlagSetBySet Assert.Equal(false, ex.Data[Mechanism.HandledKey]); } - [Fact] - public void CaptureException_ScopeCallback_DisabledHub_ExplicitHandled_DoesNotMutateException() - { - // Arrange - _ = Sut.IsEnabled.Returns(false); - var ex = new Exception(); - - // Act - var id = Sut.CaptureException(ex, _ => { }, handled: false); - - // Assert - _ = Sut.DidNotReceive().CaptureEvent(Arg.Any(), Arg.Any>()); - Assert.Equal(default, id); - Assert.False(ex.Data.Contains(Mechanism.HandledKey)); - } - [Fact] public void CaptureExceptionInternal_NoPresetFlag_DefaultsToUnhandled() { @@ -164,7 +133,7 @@ public void CaptureExceptionInternal_NoPresetFlag_DefaultsToUnhandled() } [Fact] - public void CaptureExceptionInternal_PresetFlag_IsPreserved() + public void CaptureExceptionInternal_PresetFlag_IsOverriddenToUnhandled() { // Arrange var ex = new Exception(); @@ -174,7 +143,7 @@ public void CaptureExceptionInternal_PresetFlag_IsPreserved() _ = Sut.CaptureExceptionInternal(ex); // Assert - Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + Assert.Equal(false, ex.Data[Mechanism.HandledKey]); } [Fact] diff --git a/test/Sentry.Tests/SentryClientExtensionsTests.cs b/test/Sentry.Tests/SentryClientExtensionsTests.cs index 71b215863e..4f7178a137 100644 --- a/test/Sentry.Tests/SentryClientExtensionsTests.cs +++ b/test/Sentry.Tests/SentryClientExtensionsTests.cs @@ -23,7 +23,7 @@ public void CaptureException_EnabledClient_CapturesEvent() } [Fact] - public void CaptureException_NoHandledArgument_DefaultsToHandledTrue() + public void CaptureException_NoHandledArgument_DoesNotSetHandledFlag() { // Arrange _ = _sut.IsEnabled.Returns(true); @@ -33,7 +33,7 @@ public void CaptureException_NoHandledArgument_DefaultsToHandledTrue() _ = _sut.CaptureException(ex); // Assert - Assert.Equal(true, ex.Data[Mechanism.HandledKey]); + Assert.False(ex.Data.Contains(Mechanism.HandledKey)); } [Fact] diff --git a/test/Sentry.Tests/SentrySdkTests.cs b/test/Sentry.Tests/SentrySdkTests.cs index 2196636133..6391aaba4c 100644 --- a/test/Sentry.Tests/SentrySdkTests.cs +++ b/test/Sentry.Tests/SentrySdkTests.cs @@ -672,6 +672,29 @@ public void CaptureException_WithConfiguredScope_ScopeCallbackGetsInvoked() Assert.True(scopeCallbackWasInvoked); } + [Fact] + public void CaptureException_NoHandledArgument_LeavesMechanismHandledUnset() + { + SentryEvent captured = null; + using var _ = SentrySdk.Init(o => + { + o.Dsn = ValidDsn; + o.AutoSessionTracking = false; + o.BackgroundWorker = Substitute.For(); + o.InitNativeSdks = false; + o.SetBeforeSend(e => + { + captured = e; + return e; + }); + }); + + SentrySdk.CaptureException(new Exception("test")); + + Assert.NotNull(captured); + Assert.Null(Assert.Single(captured.SentryExceptions!).Mechanism?.Handled); + } + [Theory] [InlineData(true)] [InlineData(false)] From 6d37227eb8b40c358e5392d809e757d0c82a3517 Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sun, 26 Jul 2026 10:50:10 +0200 Subject: [PATCH 4/7] feat: default Mechanism.Handled to true for user-captured exceptions MainExceptionProcessor now marks any exception no integration claimed as handled, instead of inferring it from the presence of a stack trace and leaving never-thrown exceptions as null. This fixes the symptom #3383 opens with - SentrySdk.CaptureException(new Exception()) reported "Handled --" - and matches the relay protocol docs, which are now cited at the decision site. Doing it in the processor keeps the capture overloads free of any implicit precedence: the plain overloads still write nothing to Exception.Data, and an explicit handled argument (or SetSentryMechanism) always wins. Co-Authored-By: Claude Opus 5 (1M context) --- src/Sentry/Internal/MainExceptionProcessor.cs | 16 +++++++--------- ...rTests.RecordsEfAsync.DotNet10_0.verified.txt | 1 + ...erTests.RecordsEfAsync.DotNet8_0.verified.txt | 1 + ...erTests.RecordsEfAsync.DotNet9_0.verified.txt | 1 + ...tenerTests.RecordsEfAsync.Net4_8.verified.txt | 1 + ...SqlListenerTests.RecordsSqlAsync.verified.txt | 1 + .../IntegrationTests.Simple.verified.txt | 1 + ....CreateSentryException_Aggregate.verified.txt | 2 ++ .../Internals/MainExceptionProcessorTests.cs | 3 ++- test/Sentry.Tests/SentrySdkTests.cs | 5 +++-- 10 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index 3a5d524df4..1e3d3b456f 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -174,17 +174,15 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId mechanism.Handled = handled; exception.Data.Remove(Mechanism.HandledKey); } - else if (exception.StackTrace != null) - { - // The exception was thrown, but it was caught by the user, not an integration. - // Thus, we can mark it as handled. - mechanism.Handled = true; - } else { - // The exception was never thrown. It was just constructed and then captured. - // Thus, it is neither handled nor unhandled. - mechanism.Handled = null; + // No integration claimed this exception, so it reached us because user code captured it explicitly - + // whether it was thrown and caught, or merely constructed and passed to CaptureException. Either way + // that counts as handled: + // https://getsentry.github.io/relay/relay_event_schema/protocol/struct.Mechanism.html#structfield.handled + // "Exceptions captured using capture_exception (called from user code) are handled=true as the user + // explicitly captured the exception (and therefore kind of handled it)." + mechanism.Handled = true; } if (exception.Data[Mechanism.MechanismKey] is string mechanismType) diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt index 290207f39e..3c95d6b346 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet10_0.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt index 290207f39e..3c95d6b346 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet8_0.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt index 290207f39e..3c95d6b346 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.DotNet9_0.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt index 51fc328f6a..96a1009270 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsEfAsync.Net4_8.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt index fbf3184173..b86c6fec54 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.RecordsSqlAsync.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt b/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt index 42af21ba1b..b807582bf8 100644 --- a/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt +++ b/test/Sentry.EntityFramework.Tests/IntegrationTests.Simple.verified.txt @@ -13,6 +13,7 @@ Value: my exception, Mechanism: { Type: generic, + Handled: true, Synthetic: false, IsExceptionGroup: false, Data: { diff --git a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.CreateSentryException_Aggregate.verified.txt b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.CreateSentryException_Aggregate.verified.txt index 520f33f3fa..824a7e3709 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.CreateSentryException_Aggregate.verified.txt +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.CreateSentryException_Aggregate.verified.txt @@ -5,6 +5,7 @@ Mechanism: { Type: chained, Source: InnerExceptions[1], + Handled: true, Synthetic: false, IsExceptionGroup: false, ExceptionId: 2, @@ -20,6 +21,7 @@ Mechanism: { Type: chained, Source: InnerExceptions[0], + Handled: true, Synthetic: false, IsExceptionGroup: false, ExceptionId: 1, diff --git a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs index 14151c21a8..9a65da4dbf 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs @@ -56,8 +56,9 @@ public void Process_ExceptionWithout_Handled() sut.Process(exp, evt); + // No integration set the flag, so this was captured by user code, which counts as handled. Assert.NotNull(evt.SentryExceptions); - Assert.Single(evt.SentryExceptions, p => p.Mechanism?.Handled == null); + Assert.Single(evt.SentryExceptions, p => p.Mechanism?.Handled == true); } [Fact] diff --git a/test/Sentry.Tests/SentrySdkTests.cs b/test/Sentry.Tests/SentrySdkTests.cs index 6391aaba4c..ed6f99fdb9 100644 --- a/test/Sentry.Tests/SentrySdkTests.cs +++ b/test/Sentry.Tests/SentrySdkTests.cs @@ -673,7 +673,7 @@ public void CaptureException_WithConfiguredScope_ScopeCallbackGetsInvoked() } [Fact] - public void CaptureException_NoHandledArgument_LeavesMechanismHandledUnset() + public void CaptureException_NoHandledArgument_NeverThrown_DefaultsToHandled() { SentryEvent captured = null; using var _ = SentrySdk.Init(o => @@ -691,8 +691,9 @@ public void CaptureException_NoHandledArgument_LeavesMechanismHandledUnset() SentrySdk.CaptureException(new Exception("test")); + // The plain overload writes no flag; MainExceptionProcessor infers handled for a never-thrown exception. Assert.NotNull(captured); - Assert.Null(Assert.Single(captured.SentryExceptions!).Mechanism?.Handled); + Assert.True(Assert.Single(captured.SentryExceptions!).Mechanism!.Handled); } [Theory] From 1d662b6885b4ec537e6f9370ab30d4ad33b26c51 Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sun, 26 Jul 2026 13:10:02 +0200 Subject: [PATCH 5/7] docs: trim Mechanism.Handled comment to the Relay reference The preceding two sentences restated what the quoted Relay docs already say, so keep only the authoritative reference. Co-Authored-By: Claude Opus 5 (1M context) --- src/Sentry/Internal/MainExceptionProcessor.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index 1e3d3b456f..8f4899763c 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -176,9 +176,6 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId } else { - // No integration claimed this exception, so it reached us because user code captured it explicitly - - // whether it was thrown and caught, or merely constructed and passed to CaptureException. Either way - // that counts as handled: // https://getsentry.github.io/relay/relay_event_schema/protocol/struct.Mechanism.html#structfield.handled // "Exceptions captured using capture_exception (called from user code) are handled=true as the user // explicitly captured the exception (and therefore kind of handled it)." From 0dbfc6bbc96e0e98c1e9ac998192bb2ad65685b3 Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sun, 26 Jul 2026 15:26:05 +0200 Subject: [PATCH 6/7] chore: retrigger CI (linux-arm64 and win-x64 flakes on prior runs) Co-Authored-By: Claude Opus 5 (1M context) From 3663ee549d755384ac59b03072d75db06814e1cd Mon Sep 17 00:00:00 2001 From: vladbrincoveanu Date: Sun, 26 Jul 2026 16:50:22 +0200 Subject: [PATCH 7/7] fix: don't overwrite integration-set Handled flag in CaptureExceptionInternal WinUIUnhandledExceptionIntegration forwards the platform's Handled value via SetSentryMechanism before capturing; stamping false unconditionally clobbered it. Only default to unhandled when no flag was declared. Co-Authored-By: Claude Opus 5 (1M context) --- src/Sentry/HubExtensions.cs | 7 ++++++- test/Sentry.Tests/HubExtensionsTests.cs | 12 ++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index fbf47b6b35..6da5d8cdd1 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -271,7 +271,12 @@ public LockedScope(IHub hub) internal static SentryId CaptureExceptionInternal(this IHub hub, Exception ex) { - ex.Data[Mechanism.HandledKey] = false; + // Integrations stamp the flag via SetSentryMechanism before calling this (e.g. WinUI forwards the + // platform's Handled value); only default to unhandled when nothing was declared. + if (!ex.Data.Contains(Mechanism.HandledKey)) + { + ex.Data[Mechanism.HandledKey] = false; + } return hub.CaptureEvent(new SentryEvent(ex)); } diff --git a/test/Sentry.Tests/HubExtensionsTests.cs b/test/Sentry.Tests/HubExtensionsTests.cs index 739f87b1bd..81a3b4d1d5 100644 --- a/test/Sentry.Tests/HubExtensionsTests.cs +++ b/test/Sentry.Tests/HubExtensionsTests.cs @@ -132,18 +132,22 @@ public void CaptureExceptionInternal_NoPresetFlag_DefaultsToUnhandled() _ = Sut.Received(1).CaptureEvent(Arg.Any()); } - [Fact] - public void CaptureExceptionInternal_PresetFlag_IsOverriddenToUnhandled() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CaptureExceptionInternal_PresetFlag_IsNotOverwritten(bool handled) { // Arrange + // E.g. WinUIUnhandledExceptionIntegration forwards the platform's Handled value + // via SetSentryMechanism before capturing. var ex = new Exception(); - ex.SetSentryMechanism("SomeMechanism", handled: true); + ex.SetSentryMechanism("SomeMechanism", handled: handled); // Act _ = Sut.CaptureExceptionInternal(ex); // Assert - Assert.Equal(false, ex.Data[Mechanism.HandledKey]); + Assert.Equal(handled, ex.Data[Mechanism.HandledKey]); } [Fact]