Skip to content

feat: Allow users to control Mechanism.Handled for captured exceptions - #5449

Open
vladbrincoveanu wants to merge 7 commits into
getsentry:mainfrom
vladbrincoveanu:feat/mechanism-handled-control
Open

feat: Allow users to control Mechanism.Handled for captured exceptions#5449
vladbrincoveanu wants to merge 7 commits into
getsentry:mainfrom
vladbrincoveanu:feat/mechanism-handled-control

Conversation

@vladbrincoveanu

@vladbrincoveanu vladbrincoveanu commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes #3383

Implements the approach agreed in this comment: SDK users get explicit control over Mechanism.Handled, and manually captured exceptions default to handled: true.

API changes

New overloads for explicit control:

  • SentrySdk.CaptureException(Exception exception, bool handled)
  • SentrySdk.CaptureException(Exception exception, Action<Scope> configureScope, bool handled)
  • SentryClientExtensions.CaptureException(this ISentryClient client, Exception ex, bool handled)
  • HubExtensions.CaptureException(this IHub hub, Exception ex, Action<Scope> configureScope, bool handled)

The existing signatures are kept, so this is source- and binary-compatible. handled is a required parameter rather than optional: an optional one would be permanently shadowed by the existing exact-match overloads, and replacing those signatures would be binary-breaking.

Behavior

  • Explicit wins. Passing handled records that value, overriding any flag previously set on the exception (including via SetSentryMechanism).
  • The existing overloads are untouched. They write nothing to Exception.Data, so there is no implicit precedence between a preset flag and a plain CaptureException(ex) call.
  • The default moved into MainExceptionProcessor. Any exception no integration claimed is now marked handled: true, replacing the previous "thrown ⇒ true, never thrown ⇒ null" heuristic. This is what fixes the symptom Allow SDK users to control Mechanism.Handled for captured exceptions #3383 opens with: SentrySdk.CaptureException(new Exception()) reported Handled -- and now reports Handled: true, per the protocol docs ("exceptions captured using capture_exception are handled=true"), which are cited at the decision site. Thrown-and-caught exceptions reported Handled: true before and still do.
  • CaptureExceptionInternal (the integration capture path) stamps handled: false, since integrations capture exceptions that user code did not handle.
  • Terminal semantics are unchanged. See the discussion below on terminal control for user captures and on the WinUI integration, both are proposed as follow-ups rather than expanding this PR's scope.

Testing

Unit tests cover explicit true/false on every capture path, explicit values overriding preset flags, the untouched plain overloads, and the integration-path default. Verify snapshots updated: API approvals for all four target frameworks, the aggregate-exception mechanism snapshot, and the Sentry.DiagnosticSource / Sentry.EntityFramework integration snapshots, where constructed-and-captured exceptions now report Handled: true.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.64%. Comparing base (6516897) to head (3663ee5).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5449      +/-   ##
==========================================
+ Coverage   74.62%   74.64%   +0.02%     
==========================================
  Files         512      512              
  Lines       18672    18680       +8     
  Branches     3657     3658       +1     
==========================================
+ Hits        13934    13944      +10     
+ Misses       3865     3863       -2     
  Partials      873      873              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vladbrincoveanu
vladbrincoveanu force-pushed the feat/mechanism-handled-control branch 2 times, most recently from 57b519e to ce1f8c9 Compare July 24, 2026 11:29
@vladbrincoveanu
vladbrincoveanu marked this pull request as ready for review July 25, 2026 04:21
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Jul 25, 2026
Comment thread src/Sentry/Internal/MainExceptionProcessor.cs Outdated
@vladbrincoveanu

vladbrincoveanu commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

We have a warden failing check.
Could you please have a look @jamescrosswell ?
Warden cannot review this fork pull request because GitHub withholds repository secrets from fork workflows
Thanks.

vladbrincoveanu added a commit to vladbrincoveanu/sentry-dotnet that referenced this pull request Jul 25, 2026
The Handled-inheritance change in this branch made inner exceptions inherit
Handled=false from a non-terminal unhandled outer (e.g. an unobserved-task
AggregateException marked terminal:false). Terminal was not inherited, so after
the processor strips the outer Data keys, HasUnhandledTerminalException matched
the inner (Handled:false, Terminal:null) and IsFromTerminalException() wrongly
returned true.

Propagate Terminal down the chain symmetric to Handled so the chain stays
internally consistent. Terminal is an SDK-internal, non-serialized flag consumed
only by the Has*TerminalException scans, so this has no wire/protocol impact.
Explicit Terminal flags on any exception still win.

Regression flagged by Cursor Bugbot on PR getsentry#5449.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

ex.Data[Mechanism.HandledKey] ??= handled;
return client.CaptureEvent(new SentryEvent(ex));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled capture crashes sessions

High Severity

Public CaptureException with handled: false stamps only Mechanism.HandledKey and leaves Terminal unset. Session logic treats unhandled-with-unset-terminal as UnhandledTerminal, so a manual capture ends the session as Crashed and aborts the active transaction even though the process kept running.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5e8b0b1. Configure here.

@vladbrincoveanu vladbrincoveanu Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, this is the current behavior: handled: false stamps only Mechanism.HandledKey and leaves Terminal unset. A genuinely unhandled exception (e.g. AppDomain.UnhandledException) sets handled: false with no terminal flag, which counts as a Crash (see SentryEvent.HasUnhandledTerminalException).

Adding terminal control to the CaptureException signature (defaulting Terminal = false for user-parameter captures) would expand the API surface beyond what #3383 asks for, so I kept it out of this PR , but happy to open a follow-up issue for it, if you think the session-crash default is too surprising for manual captures. What do you think? @jamescrosswell

(The related WinUI issue CaptureExceptionInternal overwriting the platform-forwarded Handled flag, has since been fixed in 3663ee5; only the session-terminal question above remains open.)

@jamescrosswell jamescrosswell Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK this is going down a bit of a 🐇 🕳️ - apologies in advance for the long reply...

The TLDR; is that I think we need to expose a way to set both Handled and Terminal (not just Handled).

Detailed response

Terminal exists to determine two things:

  1. A session's end status (Release Health)
    a. Only relevant for mobile/desktop/gaming clients (makes no sense for ASP.NET Core)
  2. Whether to abort the active

SentryEvent.GetExceptionType() collapses Handled+Terminal into three buckets:

Handled Terminal ExceptionType Consequence
not false (ignored) Handled ReportError() — session stays alive, error count++
false unset / not false UnhandledTerminal EndSession(Crashed) and aborts the active transaction
false explicitly false UnhandledNonTerminal MarkSessionAsUnhandled() — session flagged, but not ended, transaction not aborted

Session Health

So Terminal only matters when Handled: false and it's basically used to distinguish between unhandled and crashed from the perspective of Release Health. In the .net SDK, that's captured via the the SessionEndStatus in the SentryClient:

var exceptionType = processedEvent.GetExceptionType();
switch (exceptionType)
{
case SentryEvent.ExceptionType.UnhandledNonTerminal:
_options.LogDebug("Marking session as 'Unhandled', due to non-terminal unhandled exception.");
_sessionManager.MarkSessionAsUnhandled();
break;
case SentryEvent.ExceptionType.UnhandledTerminal:
_options.LogDebug("Ending session as 'Crashed', due to unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed);
break;
case SentryEvent.ExceptionType.Handled:
_options.LogDebug("Updating session by reporting an error.");
scope.SessionUpdate = _sessionManager.ReportError();
break;
}

And importantly, unhandled exceptions are terminal by default in the .NET SDK:

internal bool HasUnhandledTerminalException()
{
// Check if the original exception was unhandled and not explicitly marked as non-terminal
if (Exception?.Data[Mechanism.HandledKey] is false)
{
// If it's unhandled but explicitly marked as non-terminal, return false
if (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 SentryExceptions?.Any(e =>
e.Mechanism is { Handled: false } &&
e.Mechanism.Terminal != 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;
}

Transactions

The impact on transactions is not dependent on the app model and so impacts all apps (including ASP.NET Core).

An UnhandledTerminal exception finishes the current transaction as Aborted (except OTel transactions, which the SpanProcessor handles).

  • AppDomainUnhandledExceptionIntegrationhandled: false, no terminal ⇒ Terminal/Crashed. (Console, generic .NET, and the process is genuinely dying.)
  • WinUIUnhandledExceptionIntegration — passes through the platform's Handled bool; if !handled, terminal/Crashed.
  • UnobservedTaskExceptionIntegration — the one built-in case that sets terminal: false (async-void / unawaited Task.Run — unhandled but doesn't crash).
  • SentryMiddleware and AspNetCoreExceptionProcessor — mark handled: false, no terminal flag ⇒ No session tracking in ASP.NET Core and so all this does is terminate the Transaction with an error state, which is the desired behaviour for a 5xx.

What this means for this PR

CaptureException(ex, handled: false) stamps only HandledKey, leaves Terminal unset, so it always classifies Unhandled as UnhandledTerminal = Crash.

  • ASP.NET Core / web: Manual handled: false capture will abort the active transaction, which might be surprising to SDK users!!!
  • MAUI / desktop clients: Manual handled: false capture will end the session as Crashed and open a new one, denting the crash-free-sessions rate — even though nothing actually crashed. Again, possibly surprising to SDK users manually setting handled to false.

So I don't think we can expose handled without also exposing a way to set terminal - since without that you allow SDK users to specify UnhandledTerminal (crash) but no way to specify UnhandledNonTerminal (unhandled but not a crash).

@vladbrincoveanu vladbrincoveanu Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi James,
Thanks for taking the time to explain this.
I will take care of the comments next week Monday. Should I leave the PR in an opened state until then?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's marked as Changes Requested so all good to leave it open.

Thanks @vladbrincoveanu !

@vladbrincoveanu
vladbrincoveanu force-pushed the feat/mechanism-handled-control branch 2 times, most recently from 6345138 to 801db87 Compare July 25, 2026 06:07
@vladbrincoveanu
vladbrincoveanu marked this pull request as draft July 25, 2026 19:14
getsentry#3383)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vladbrincoveanu
vladbrincoveanu force-pushed the feat/mechanism-handled-control branch from 061f5d3 to 0540a78 Compare July 26, 2026 04:48
vladbrincoveanu and others added 4 commits July 26, 2026 08:33
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 getsentry#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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@vladbrincoveanu
vladbrincoveanu marked this pull request as ready for review July 26, 2026 12:15

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1d662b6. Configure here.

Comment thread src/Sentry/HubExtensions.cs
Comment thread src/Sentry/HubExtensions.cs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/Sentry/Internal/MainExceptionProcessor.cs
…Internal

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) <noreply@anthropic.com>

@jamescrosswell jamescrosswell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vladbrincoveanu thank you very much for the PR... it does raise some tricky problems though - see my inline comments below.

// 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;

@jamescrosswell jamescrosswell Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a change in behaviour. I think it probably makes sense... we can probably do it without making a major release - arguably it's a bug fix rather than an arbitrary change.

@dingsdax do you have any steer?

Comment on lines +274 to +275
// 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest we make this a bit clearer. This method is internal and the mechanism is always set by internal integrations before it's called.

We can add it - I think we should note in the comment that it's defensive only, if we do.

Suggested change
// 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.
// Integrations always call `SetSentryMechanism` before calling this method, so
// this fallback is defensive only. In practice, this code never executes.

/// <param name="handled">Whether the exception was handled. Recorded on the exception, overriding any flag
/// previously set on it, including one set via <see cref="SentryExceptionExtensions.SetSentryMechanism"/>.</param>
/// <returns>The Id of the event</returns>
public static SentryId CaptureException(this IHub hub, Exception ex, Action<Scope> configureScope, bool handled)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest reordering the parameters. The action will likely be a lambda function which could be arbitrarily long/complex. Having the bool as the last parameter would make things harder to read.

Suggested change
public static SentryId CaptureException(this IHub hub, Exception ex, Action<Scope> configureScope, bool handled)
public static SentryId CaptureException(this IHub hub, Exception ex, bool handled, Action<Scope> configureScope)

Comment thread src/Sentry/SentrySdk.cs
/// previously set on it, including one set via <see cref="SentryExceptionExtensions.SetSentryMechanism"/>.</param>
/// <returns>The Id of the event.</returns>
[DebuggerStepThrough]
public static SentryId CaptureException(Exception exception, Action<Scope> configureScope, bool handled)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See related comment above.

Suggested change
public static SentryId CaptureException(Exception exception, Action<Scope> configureScope, bool handled)
public static SentryId CaptureException(Exception exception, bool handled, Action<Scope> configureScope)


ex.Data[Mechanism.HandledKey] ??= handled;
return client.CaptureEvent(new SentryEvent(ex));
}

@jamescrosswell jamescrosswell Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK this is going down a bit of a 🐇 🕳️ - apologies in advance for the long reply...

The TLDR; is that I think we need to expose a way to set both Handled and Terminal (not just Handled).

Detailed response

Terminal exists to determine two things:

  1. A session's end status (Release Health)
    a. Only relevant for mobile/desktop/gaming clients (makes no sense for ASP.NET Core)
  2. Whether to abort the active

SentryEvent.GetExceptionType() collapses Handled+Terminal into three buckets:

Handled Terminal ExceptionType Consequence
not false (ignored) Handled ReportError() — session stays alive, error count++
false unset / not false UnhandledTerminal EndSession(Crashed) and aborts the active transaction
false explicitly false UnhandledNonTerminal MarkSessionAsUnhandled() — session flagged, but not ended, transaction not aborted

Session Health

So Terminal only matters when Handled: false and it's basically used to distinguish between unhandled and crashed from the perspective of Release Health. In the .net SDK, that's captured via the the SessionEndStatus in the SentryClient:

var exceptionType = processedEvent.GetExceptionType();
switch (exceptionType)
{
case SentryEvent.ExceptionType.UnhandledNonTerminal:
_options.LogDebug("Marking session as 'Unhandled', due to non-terminal unhandled exception.");
_sessionManager.MarkSessionAsUnhandled();
break;
case SentryEvent.ExceptionType.UnhandledTerminal:
_options.LogDebug("Ending session as 'Crashed', due to unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed);
break;
case SentryEvent.ExceptionType.Handled:
_options.LogDebug("Updating session by reporting an error.");
scope.SessionUpdate = _sessionManager.ReportError();
break;
}

And importantly, unhandled exceptions are terminal by default in the .NET SDK:

internal bool HasUnhandledTerminalException()
{
// Check if the original exception was unhandled and not explicitly marked as non-terminal
if (Exception?.Data[Mechanism.HandledKey] is false)
{
// If it's unhandled but explicitly marked as non-terminal, return false
if (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 SentryExceptions?.Any(e =>
e.Mechanism is { Handled: false } &&
e.Mechanism.Terminal != 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;
}

Transactions

The impact on transactions is not dependent on the app model and so impacts all apps (including ASP.NET Core).

An UnhandledTerminal exception finishes the current transaction as Aborted (except OTel transactions, which the SpanProcessor handles).

  • AppDomainUnhandledExceptionIntegrationhandled: false, no terminal ⇒ Terminal/Crashed. (Console, generic .NET, and the process is genuinely dying.)
  • WinUIUnhandledExceptionIntegration — passes through the platform's Handled bool; if !handled, terminal/Crashed.
  • UnobservedTaskExceptionIntegration — the one built-in case that sets terminal: false (async-void / unawaited Task.Run — unhandled but doesn't crash).
  • SentryMiddleware and AspNetCoreExceptionProcessor — mark handled: false, no terminal flag ⇒ No session tracking in ASP.NET Core and so all this does is terminate the Transaction with an error state, which is the desired behaviour for a 5xx.

What this means for this PR

CaptureException(ex, handled: false) stamps only HandledKey, leaves Terminal unset, so it always classifies Unhandled as UnhandledTerminal = Crash.

  • ASP.NET Core / web: Manual handled: false capture will abort the active transaction, which might be surprising to SDK users!!!
  • MAUI / desktop clients: Manual handled: false capture will end the session as Crashed and open a new one, denting the crash-free-sessions rate — even though nothing actually crashed. Again, possibly surprising to SDK users manually setting handled to false.

So I don't think we can expose handled without also exposing a way to set terminal - since without that you allow SDK users to specify UnhandledTerminal (crash) but no way to specify UnhandledNonTerminal (unhandled but not a crash).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow SDK users to control Mechanism.Handled for captured exceptions

2 participants