Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c3d01c1
Initial setup
bitsandfoxes Oct 14, 2025
38ac24d
Move terminal state into mechanism
bitsandfoxes Oct 15, 2025
54d3b2f
Format code
getsentry-bot Oct 15, 2025
f44a687
Made Terminal nullable
bitsandfoxes Oct 15, 2025
de5e1b5
Merge branch 'feat/session-type-unhandled' of https://github.com/gets…
bitsandfoxes Oct 15, 2025
b6c4c58
Updated CHANGELOG.md
bitsandfoxes Oct 15, 2025
dec8f34
Bump because vulnerability
bitsandfoxes Oct 15, 2025
16179d8
Conditionally add the terminal key
bitsandfoxes Oct 15, 2025
3ecb136
Updated verify
bitsandfoxes Oct 16, 2025
d657e0b
merged version6
bitsandfoxes Oct 16, 2025
f95bff3
Merge branch 'version6' into feat/session-type-unhandled
bitsandfoxes Oct 17, 2025
fac1ab4
Moved 'terminal' into data bag
bitsandfoxes Oct 17, 2025
7a59034
Keep the key
bitsandfoxes Oct 17, 2025
0c47b3f
Updated verify for net48
bitsandfoxes Oct 20, 2025
04d8889
Wrap exception type with enum
bitsandfoxes Oct 20, 2025
4c24f51
Logging
bitsandfoxes Oct 20, 2025
bbefdd3
Prevent Mechanism.TerminalKey from being serialized
bitsandfoxes Oct 20, 2025
fe4e915
Filter Terminal in WriteTo
bitsandfoxes Oct 20, 2025
31416f9
Make TerminalKey top level but don't serialize
bitsandfoxes Oct 20, 2025
1ad719c
Fixed tests
bitsandfoxes Oct 20, 2025
4d6fb3f
Replaced API
bitsandfoxes Oct 20, 2025
624524f
Added net4_8 verify
bitsandfoxes Oct 20, 2025
a41b316
Update src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs
bitsandfoxes Oct 24, 2025
352fd01
Merge branch 'version6' into feat/session-type-unhandled
bitsandfoxes Oct 24, 2025
a1500c3
Unhandled -> UnhandledTerminal
bitsandfoxes Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@

See https://develop.sentry.dev/sdk/telemetry/traces/distributed-tracing/#w3c-trace-context-header for more details.

### Features

- The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633))

### Fixes

- The SDK avoids redundant scope sync after transaction finish ([#4623](https://github.com/getsentry/sentry-dotnet/pull/4623))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ internal void Handle(object? sender, UnobservedTaskExceptionEventArgs e)
MechanismKey,
"This exception was thrown from a task that was unobserved, such as from an async void method, or " +
"a Task.Run that was not awaited. This exception was unhandled, but likely did not crash the application.",
handled: false);
handled: false,
terminal: false);

// Call the internal implementation, so that we still capture even if the hub has been disabled.
_hub.CaptureExceptionInternal(ex);
Expand Down
3 changes: 2 additions & 1 deletion src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,8 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope)
scope.LastEventId = id;
scope.SessionUpdate = null;

if (evt.HasTerminalException() && scope.Transaction is { } transaction)
if (evt.GetExceptionType() is SentryEvent.ExceptionType.UnhandledTerminal
&& scope.Transaction is { } transaction)
{
// Event contains a terminal exception -> finish any current transaction as aborted
// Do this *after* the event was captured, so that the event is still linked to the transaction.
Expand Down
6 changes: 6 additions & 0 deletions src/Sentry/Internal/MainExceptionProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId
exception.Data.Remove(Mechanism.DescriptionKey);
}

if (exception.Data[Mechanism.TerminalKey] is bool terminal)
{
mechanism.Terminal = terminal;
exception.Data.Remove(Mechanism.TerminalKey);
}

// Add HResult to mechanism data before adding exception data, so that it can be overridden.
mechanism.Data["HResult"] = $"0x{exception.HResult:X8}";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ public SentryEvent Process(SentryEvent @event, SentryHint hint)

try
{
if (_logCatIntegrationType != LogCatIntegrationType.All && !@event.HasException())
var exceptionType = @event.GetExceptionType();

if (_logCatIntegrationType != LogCatIntegrationType.All && exceptionType == SentryEvent.ExceptionType.None)
{
return @event;
}

// Only send logcat logs if the event is unhandled if the integration is set to Unhandled
if (_logCatIntegrationType == LogCatIntegrationType.Unhandled)
{
if (!@event.HasTerminalException())
if (exceptionType != SentryEvent.ExceptionType.UnhandledTerminal && exceptionType != SentryEvent.ExceptionType.UnhandledNonTerminal)
{
return @event;
}
Expand Down
18 changes: 18 additions & 0 deletions src/Sentry/Protocol/Mechanism.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public sealed class Mechanism : ISentryJsonSerializable
/// </summary>
public static readonly string DescriptionKey = "Sentry:Description";

/// <summary>
/// Key found inside of <c>Exception.Data</c> describing whether the exception is considered terminal.
/// </summary>
/// <remarks>
/// This is an SDK-internal flag used for session tracking and is not sent to Sentry servers.
/// </remarks>
public static readonly string TerminalKey = "Sentry:Terminal";

internal Dictionary<string, object>? InternalData { get; private set; }

internal Dictionary<string, object>? InternalMeta { get; private set; }
Expand Down Expand Up @@ -76,6 +84,15 @@ public string Type
/// </summary>
public bool? Handled { get; set; }

/// <summary>
/// Optional flag indicating whether the exception is terminal (will crash the application).
/// When false, indicates a non-terminal unhandled exception (e.g., unobserved task exception).
/// </summary>
/// <remarks>
/// This is an SDK-internal flag used for session tracking and is not serialized to Sentry servers.
/// </remarks>
public bool? Terminal { get; internal set; }

/// <summary>
/// Optional flag indicating whether the exception is synthetic.
/// </summary>
Expand Down Expand Up @@ -133,6 +150,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
writer.WriteStringIfNotWhiteSpace("source", Source);
writer.WriteStringIfNotWhiteSpace("help_link", HelpLink);
writer.WriteBooleanIfNotNull("handled", Handled);
// Note: Terminal is NOT serialized - it's SDK-internal only
writer.WriteBooleanIfTrue("synthetic", Synthetic);
writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup);
writer.WriteNumberIfNotNull("exception_id", ExceptionId);
Expand Down
29 changes: 17 additions & 12 deletions src/Sentry/SentryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,18 +347,23 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope)
return SentryId.Empty; // Dropped by BeforeSend callback
}

var hasTerminalException = processedEvent.HasTerminalException();
if (hasTerminalException)
{
// Event contains a terminal exception -> end session as crashed
_options.LogDebug("Ending session as Crashed, due to unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed);
}
else if (processedEvent.HasException())
{
// Event contains a non-terminal exception -> report error
// (this might return null if the session has already reported errors before)
scope.SessionUpdate = _sessionManager.ReportError();
var exceptionType = processedEvent.GetExceptionType();
switch (exceptionType)
{
case SentryEvent.ExceptionType.UnhandledNonTerminal:
_options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled);
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;
}

if (_options.SampleRate != null)
Expand Down
57 changes: 50 additions & 7 deletions src/Sentry/SentryEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,21 +178,64 @@ public IReadOnlyList<string> Fingerprint
/// <inheritdoc />
public IReadOnlyDictionary<string, string> Tags => _tags ??= new Dictionary<string, string>();

internal bool HasException() => Exception is not null || SentryExceptions?.Any() == true;
internal enum ExceptionType
{
None,
Handled,
UnhandledTerminal,
UnhandledNonTerminal
}

internal bool HasTerminalException()
internal ExceptionType GetExceptionType()
{
// The exception is considered terminal if it is marked unhandled,
// UNLESS it comes from the UnobservedTaskExceptionIntegration
if (!HasException())
{
return ExceptionType.None;
}

if (HasUnhandledNonTerminalException())
{
return ExceptionType.UnhandledNonTerminal;
}

if (HasUnhandledException())
{
return ExceptionType.UnhandledTerminal;
}

return ExceptionType.Handled;
}

private bool HasException() => Exception is not null || SentryExceptions?.Any() == true;

private bool HasUnhandledException()
{
if (Exception?.Data[Mechanism.HandledKey] is false)
{
return Exception.Data[Mechanism.MechanismKey] as string != UnobservedTaskExceptionIntegration.MechanismKey;
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 } mechanism &&
mechanism.Type != UnobservedTaskExceptionIntegration.MechanismKey
e.Mechanism is { Handled: false, Terminal: false }
) ?? false;
}

Expand Down
12 changes: 11 additions & 1 deletion src/Sentry/SentryExceptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ public static void AddSentryContext(this Exception ex, string name, IReadOnlyDic
/// <param name="type">A required short string that identifies the mechanism.</param>
/// <param name="description">An optional human-readable description of the mechanism.</param>
/// <param name="handled">An optional flag indicating whether the exception was handled by the mechanism.</param>
/// <param name="terminal">An optional flag indicating whether the exception is considered terminal.</param>
public static void SetSentryMechanism(this Exception ex, string type, string? description = null,
bool? handled = null)
bool? handled = null, bool? terminal = null)
Copy link
Collaborator

Choose a reason for hiding this comment

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

The only thing I don't like about this is that it allows six possible combinations, some of which are inconsistent... terminal but handled doesn't make sense right? Also terminal + handled == null would be weird.

A nullable enum { Handled, Unhandled, Terminal } would constrain this to only consistent possibilities.

Copy link
Contributor Author

@bitsandfoxes bitsandfoxes Oct 29, 2025

Choose a reason for hiding this comment

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

The protocol itself has handled and terminal and imo, the public API should mirror this instead of introducing an abstraction that needs to be transformed back and forth.

The combinations do have legitimate use cases:

  • handled: true, terminal: false: Standard caught exception (try-catch)
  • handled: false, terminal: true: Unhandled exception that crashes the app
  • handled: false, terminal: false: Unhandled but non-terminal (e.g., UnobservedTaskException, unhandled or logged exceptions in Unity)
  • handled: true, terminal: true: Could occur when an exception is caught but the app decides to terminate anyway, or when a shutdown is triggered during exception handling which is the only case the SDK does not set

The terminal flag is also SDK internal and not getting sent to Sentry and I'd argue most users will never set it directly, so I'd rather opt towards SDK needs than to theoretical type safety that a nullable enum might bring.

{
ex.Data[Mechanism.MechanismKey] = type;

Expand All @@ -54,5 +55,14 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de
{
ex.Data[Mechanism.HandledKey] = handled;
}

if (terminal == null)
{
ex.Data.Remove(Mechanism.TerminalKey);
}
else
{
ex.Data[Mechanism.TerminalKey] = terminal;
}
}
}
7 changes: 6 additions & 1 deletion src/Sentry/SessionEndStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ public enum SessionEndStatus
/// <summary>
/// Session ended with an unhandled exception.
/// </summary>
Unhandled,

/// <summary>
/// Session ended with a terminal unhandled exception.
/// </summary>
Crashed,

/// <summary>
/// Session ended abnormally (e.g. device lost power).
/// </summary>
Abnormal
Abnormal,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can/Would we ever set this in practice? I see it's used in the tests...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a good point and I think we're abusing this in the Unity SDK. It's a bit outside of the scope of this PR but I'll check when taking a stab at properly shutting down sessions during application closure.

}
Original file line number Diff line number Diff line change
Expand Up @@ -1065,8 +1065,9 @@ namespace Sentry
public enum SessionEndStatus
{
Exited = 0,
Crashed = 1,
Abnormal = 2,
Unhandled = 1,
Crashed = 2,
Abnormal = 3,
}
public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession
{
Expand Down Expand Up @@ -1786,6 +1787,7 @@ namespace Sentry.Protocol
public static readonly string DescriptionKey;
public static readonly string HandledKey;
public static readonly string MechanismKey;
public static readonly string TerminalKey;
public Mechanism() { }
public System.Collections.Generic.IDictionary<string, object> Data { get; }
public string? Description { get; set; }
Expand All @@ -1797,6 +1799,7 @@ namespace Sentry.Protocol
public int? ParentId { get; set; }
public string? Source { get; set; }
public bool Synthetic { get; set; }
public bool? Terminal { get; }
public string Type { get; set; }
public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { }
public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { }
Expand Down Expand Up @@ -1925,5 +1928,5 @@ public static class SentryExceptionExtensions
{
public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary<string, object> data) { }
public static void AddSentryTag(this System.Exception ex, string name, string value) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -1065,8 +1065,9 @@ namespace Sentry
public enum SessionEndStatus
{
Exited = 0,
Crashed = 1,
Abnormal = 2,
Unhandled = 1,
Crashed = 2,
Abnormal = 3,
}
public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession
{
Expand Down Expand Up @@ -1786,6 +1787,7 @@ namespace Sentry.Protocol
public static readonly string DescriptionKey;
public static readonly string HandledKey;
public static readonly string MechanismKey;
public static readonly string TerminalKey;
public Mechanism() { }
public System.Collections.Generic.IDictionary<string, object> Data { get; }
public string? Description { get; set; }
Expand All @@ -1797,6 +1799,7 @@ namespace Sentry.Protocol
public int? ParentId { get; set; }
public string? Source { get; set; }
public bool Synthetic { get; set; }
public bool? Terminal { get; }
public string Type { get; set; }
public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { }
public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { }
Expand Down Expand Up @@ -1925,5 +1928,5 @@ public static class SentryExceptionExtensions
{
public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary<string, object> data) { }
public static void AddSentryTag(this System.Exception ex, string name, string value) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -1065,8 +1065,9 @@ namespace Sentry
public enum SessionEndStatus
{
Exited = 0,
Crashed = 1,
Abnormal = 2,
Unhandled = 1,
Crashed = 2,
Abnormal = 3,
}
public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession
{
Expand Down Expand Up @@ -1786,6 +1787,7 @@ namespace Sentry.Protocol
public static readonly string DescriptionKey;
public static readonly string HandledKey;
public static readonly string MechanismKey;
public static readonly string TerminalKey;
public Mechanism() { }
public System.Collections.Generic.IDictionary<string, object> Data { get; }
public string? Description { get; set; }
Expand All @@ -1797,6 +1799,7 @@ namespace Sentry.Protocol
public int? ParentId { get; set; }
public string? Source { get; set; }
public bool Synthetic { get; set; }
public bool? Terminal { get; }
public string Type { get; set; }
public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { }
public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { }
Expand Down Expand Up @@ -1925,5 +1928,5 @@ public static class SentryExceptionExtensions
{
public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary<string, object> data) { }
public static void AddSentryTag(this System.Exception ex, string name, string value) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { }
}
9 changes: 6 additions & 3 deletions test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1041,8 +1041,9 @@ namespace Sentry
public enum SessionEndStatus
{
Exited = 0,
Crashed = 1,
Abnormal = 2,
Unhandled = 1,
Crashed = 2,
Abnormal = 3,
}
public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession
{
Expand Down Expand Up @@ -1757,6 +1758,7 @@ namespace Sentry.Protocol
public static readonly string DescriptionKey;
public static readonly string HandledKey;
public static readonly string MechanismKey;
public static readonly string TerminalKey;
public Mechanism() { }
public System.Collections.Generic.IDictionary<string, object> Data { get; }
public string? Description { get; set; }
Expand All @@ -1768,6 +1770,7 @@ namespace Sentry.Protocol
public int? ParentId { get; set; }
public string? Source { get; set; }
public bool Synthetic { get; set; }
public bool? Terminal { get; }
public string Type { get; set; }
public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { }
public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { }
Expand Down Expand Up @@ -1896,5 +1899,5 @@ public static class SentryExceptionExtensions
{
public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary<string, object> data) { }
public static void AddSentryTag(this System.Exception ex, string name, string value) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default) { }
public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { }
}
Loading
Loading