From c3d01c1488f973850ea7debd0e039f19c20f3250 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 14 Oct 2025 18:03:04 +0200 Subject: [PATCH 01/30] Initial setup --- .../UnobservedTaskExceptionIntegration.cs | 5 +- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/Protocol/Mechanism.cs | 5 + src/Sentry/SentryClient.cs | 14 +- src/Sentry/SentryEvent.cs | 29 +++- src/Sentry/SentryExceptionExtensions.cs | 4 +- src/Sentry/SentryOptions.cs | 2 +- src/Sentry/SessionEndStatus.cs | 7 +- test/Sentry.Tests/HubTests.cs | 78 +++++++++++ .../Sentry.Tests/Protocol/SentryEventTests.cs | 132 ++++++++++++++++++ test/Sentry.Tests/SentryClientTests.cs | 34 +++-- 11 files changed, 280 insertions(+), 32 deletions(-) diff --git a/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs b/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs index a403186904..851351c3bf 100644 --- a/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs +++ b/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs @@ -12,7 +12,7 @@ internal class UnobservedTaskExceptionIntegration : ISdkIntegration internal UnobservedTaskExceptionIntegration(IAppDomain? appDomain = null) => _appDomain = appDomain ?? AppDomainAdapter.Instance; - public void Register(IHub hub, SentryOptions _) + public void Register(IHub hub, SentryOptions options) { _hub = hub; _appDomain.UnobservedTaskException += Handle; @@ -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); diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 9034f0d666..b8130990bd 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -576,7 +576,7 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope) { // 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. - _options.LogDebug("Ending transaction as Aborted, due to unhandled exception."); + _options.LogDebug("Ending transaction as Aborted, due to terminal unhandled exception."); transaction.Finish(SpanStatus.Aborted); } diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index ff0eb014fd..480d47d99d 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -29,6 +29,11 @@ public sealed class Mechanism : ISentryJsonSerializable /// public static readonly string DescriptionKey = "Sentry:Description"; + /// + /// Key found inside of Exception.Data describing whether the exception is considered terminal + /// + public static readonly string TerminalKey = "Sentry:Terminal"; + internal Dictionary? InternalData { get; private set; } internal Dictionary? InternalMeta { get; private set; } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 1467ee4364..c6f43515d2 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -361,17 +361,21 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return SentryId.Empty; // Dropped by BeforeSend callback } - var hasTerminalException = processedEvent.HasTerminalException(); - if (hasTerminalException) + if (processedEvent.HasUnhandledNonTerminalException()) { - // Event contains a terminal exception -> end session as crashed - _options.LogDebug("Ending session as Crashed, due to unhandled exception."); + _options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception."); + scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled); + } + else if (processedEvent.HasUnhandledException()) + { + _options.LogDebug("Ending session as 'Crashed', due to terminal unhandled exception."); scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed); } else if (processedEvent.HasException()) { - // Event contains a non-terminal exception -> report error + // Event contains a handled exception -> Report error // (this might return null if the session has already reported errors before) + _options.LogDebug("Updating session by reporting an error."); scope.SessionUpdate = _sessionManager.ReportError(); } diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 81abbe4ddd..901cea8221 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -180,22 +180,39 @@ public IReadOnlyList Fingerprint internal bool HasException() => Exception is not null || SentryExceptions?.Any() == true; - internal bool HasTerminalException() + internal bool HasUnhandledException() { - // The exception is considered terminal if it is marked unhandled, - // UNLESS it comes from the UnobservedTaskExceptionIntegration + if (Exception?.Data[Mechanism.HandledKey] is false) + { + return true; + } + + return SentryExceptions?.Any(e => e.Mechanism is { Handled: false }) ?? false; + } + + internal bool HasUnhandledNonTerminalException() + { + // Generally, an unhandled exception is considered terminal. + // Exception: If it is an unhandled exception but the mechanism key is found in NonTerminalMechanismKeys + // I.e. captured through the UnobservedTaskExceptionIntegration, or the exception capture integrations in the Unity SDK if (Exception?.Data[Mechanism.HandledKey] is false) { - return Exception.Data[Mechanism.MechanismKey] as string != UnobservedTaskExceptionIntegration.MechanismKey; + 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 } mechanism && mechanism.Data[Mechanism.TerminalKey] is false ) ?? false; } + internal bool HasTerminalException() => HasUnhandledException() && !HasUnhandledNonTerminalException(); + internal DynamicSamplingContext? DynamicSamplingContext { get; set; } /// diff --git a/src/Sentry/SentryExceptionExtensions.cs b/src/Sentry/SentryExceptionExtensions.cs index 4ecac3239b..a4b474f165 100644 --- a/src/Sentry/SentryExceptionExtensions.cs +++ b/src/Sentry/SentryExceptionExtensions.cs @@ -32,10 +32,12 @@ public static void AddSentryContext(this Exception ex, string name, IReadOnlyDic /// A required short string that identifies the mechanism. /// An optional human-readable description of the mechanism. /// An optional flag indicating whether the exception was handled by the mechanism. + /// An optional flag indicating whether the exception is considered terminal. public static void SetSentryMechanism(this Exception ex, string type, string? description = null, - bool? handled = null) + bool? handled = null, bool terminal = true) { ex.Data[Mechanism.MechanismKey] = type; + ex.Data[Mechanism.TerminalKey] = terminal; if (string.IsNullOrWhiteSpace(description)) { diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 4e2983cb91..d08014d424 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -223,7 +223,7 @@ internal IEnumerable Integrations } } } - + internal List? ExceptionFilters { get; set; } = new(); /// diff --git a/src/Sentry/SessionEndStatus.cs b/src/Sentry/SessionEndStatus.cs index fdde497533..5c483c6b5e 100644 --- a/src/Sentry/SessionEndStatus.cs +++ b/src/Sentry/SessionEndStatus.cs @@ -13,10 +13,15 @@ public enum SessionEndStatus /// /// Session ended with an unhandled exception. /// + Unhandled, + + /// + /// Session ended with a terminal unhandled exception. + /// Crashed, /// /// Session ended abnormally (e.g. device lost power). /// - Abnormal + Abnormal, } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 342d38f84d..a79f9d7eee 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -524,6 +524,84 @@ public void CaptureEvent_Client_GetsHint() Arg.Any(), Arg.Is(h => h == hint)); } + [Fact] + public void CaptureEvent_TerminalUnhandledException_AbortsActiveTransaction() + { + // Arrange + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + + var transaction = hub.StartTransaction("test", "operation"); + hub.ConfigureScope(scope => scope.Transaction = transaction); + + var exception = new Exception("test"); + exception.SetSentryMechanism("test", handled: false); + + // Act + hub.CaptureEvent(new SentryEvent(exception)); + + // Assert + transaction.Status.Should().Be(SpanStatus.Aborted); + transaction.IsFinished.Should().BeTrue(); + } + + [Fact] + public void CaptureEvent_NonTerminalUnhandledException_DoesNotAbortActiveTransaction() + { + // Arrange + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + + var transaction = hub.StartTransaction("test", "operation"); + hub.ConfigureScope(scope => scope.Transaction = transaction); + + var exception = new Exception("test"); + exception.SetSentryMechanism("TestException", handled: false, terminal: false); + + // Act + hub.CaptureEvent(new SentryEvent(exception)); + + // Assert + transaction.IsFinished.Should().BeFalse(); + } + + [Fact] + public void CaptureEvent_HandledException_DoesNotAbortActiveTransaction() + { + // Arrange + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + + var transaction = hub.StartTransaction("test", "operation"); + hub.ConfigureScope(scope => scope.Transaction = transaction); + + var exception = new Exception("test"); + exception.SetSentryMechanism("test", handled: true); + + // Act + hub.CaptureEvent(new SentryEvent(exception)); + + // Assert + transaction.IsFinished.Should().BeFalse(); + } + + [Fact] + public void CaptureEvent_EventWithoutException_DoesNotAbortActiveTransaction() + { + // Arrange + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + + var transaction = hub.StartTransaction("test", "operation"); + hub.ConfigureScope(scope => scope.Transaction = transaction); + + // Act + hub.CaptureEvent(new SentryEvent { Message = "test message" }); + + // Assert + transaction.IsFinished.Should().BeFalse(); + } + [Fact] public void AppDomainUnhandledExceptionIntegration_ActiveSession_UnhandledExceptionSessionEndedAsCrashed() { diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index 37b397c548..1dfb81e226 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -167,4 +167,136 @@ public void Redact_Redacts_Urls() evt.Tags["tag_key"].Should().Be(tagValue); } } + + [Fact] + public void HasUnhandledException_WithUnhandledException_ReturnsTrue() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("test", handled: false); + var evt = new SentryEvent(exception); + + Assert.True(evt.HasUnhandledException()); + } + + [Fact] + public void HasUnhandledException_WithHandledException_ReturnsFalse() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("test", handled: true); + var evt = new SentryEvent(exception); + + Assert.False(evt.HasUnhandledException()); + } + + [Fact] + public void HasUnhandledException_WithSentryExceptions_Unhandled_ReturnsTrue() + { + var evt = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Mechanism = new Mechanism { Handled = false } + } + } + }; + + Assert.True(evt.HasUnhandledException()); + } + + [Fact] + public void HasUnhandledException_WithSentryExceptions_Handled_ReturnsFalse() + { + var evt = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Mechanism = new Mechanism { Handled = true } + } + } + }; + + Assert.False(evt.HasUnhandledException()); + } + + [Fact] + public void HasUnhandledNonTerminalException_WithNonTerminalMechanism_ReturnsTrue() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("UnobservedTaskException", handled: false, terminal: false); + var evt = new SentryEvent(exception); + + Assert.True(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasUnhandledNonTerminalException_WithTerminalMechanism_ReturnsFalse() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); + var evt = new SentryEvent(exception); + + Assert.False(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasUnhandledNonTerminalException_WithHandledException_ReturnsFalse() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("UnobservedTaskException", handled: true); + var evt = new SentryEvent(exception); + + Assert.False(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasUnhandledNonTerminalException_NoMechanismKey_ReturnsFalse() + { + var exception = new Exception("test"); + exception.Data[Mechanism.HandledKey] = false; + var evt = new SentryEvent(exception); + + Assert.False(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasTerminalException_WithTerminalUnhandledException_ReturnsTrue() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); + var evt = new SentryEvent(exception); + + Assert.True(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasTerminalException_WithNonTerminalException_ReturnsFalse() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("UnobservedTaskException", handled: false, terminal: false); + var evt = new SentryEvent(exception); + + Assert.False(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasTerminalException_WithHandledException_ReturnsFalse() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("test", handled: true); + var evt = new SentryEvent(exception); + + Assert.False(evt.HasUnhandledNonTerminalException()); + } + + [Fact] + public void HasTerminalException_NoException_ReturnsFalse() + { + var evt = new SentryEvent(); + + Assert.False(evt.HasUnhandledNonTerminalException()); + } } diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index a2c3782638..00a1a6fee8 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1692,27 +1692,31 @@ public void CaptureEvent_Exception_ReportsError() } [Fact] - public void CaptureEvent_ActiveSession_UnhandledExceptionSessionEndedAsCrashed() + public void CaptureEvent_ActiveSessionAndUnhandledException_SessionEndedAsCrashed() { // Arrange var client = _fixture.GetSut(); + var exception = new Exception(); + exception.SetSentryMechanism("TestException", handled: false, terminal: true); // Act - client.CaptureEvent(new SentryEvent() - { - SentryExceptions = new[] - { - new SentryException - { - Mechanism = new() - { - Handled = false - } - } - } - }); - + client.CaptureEvent(new SentryEvent(exception)); // Assert _fixture.SessionManager.Received().EndSession(SessionEndStatus.Crashed); } + + [Fact] + public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEndedAsUnhandled() + { + // Arrange + var client = _fixture.GetSut(); + var exception = new Exception(); + exception.SetSentryMechanism("TestException", handled: false, terminal: false); + + // Act + client.CaptureEvent(new SentryEvent(exception)); + + // Assert + _fixture.SessionManager.Received().EndSession(SessionEndStatus.Unhandled); + } } From 38ac24d2a5e1a59a5af19b44622157d277a0d463 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 15 Oct 2025 11:46:30 +0200 Subject: [PATCH 02/30] Move terminal state into mechanism --- src/Sentry/Internal/MainExceptionProcessor.cs | 7 +++++++ src/Sentry/Protocol/Mechanism.cs | 14 ++++++++++++++ src/Sentry/SentryEvent.cs | 4 ++-- src/Sentry/SentryExceptionExtensions.cs | 3 ++- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 9 ++++++--- .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 9 ++++++--- .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 9 ++++++--- test/Sentry.Tests/HubTests.cs | 2 +- .../Protocol/Exceptions/MechanismTests.cs | 4 ++++ test/Sentry.Tests/Protocol/SentryEventTests.cs | 8 ++++---- 10 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index b80ab1ea94..f3ffd498d4 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -187,6 +187,13 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId mechanism.Handled = null; } + if (exception.Data[Mechanism.TerminalKey] is bool terminal) + { + // The mechanism terminal flag was set by an integration. + mechanism.Terminal = terminal; + exception.Data.Remove(Mechanism.TerminalKey); + } + if (exception.Data[Mechanism.MechanismKey] is string mechanismType) { mechanism.Type = mechanismType; diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 480d47d99d..91ce77a960 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -81,6 +81,16 @@ public string Type /// public bool? Handled { get; set; } + /// + /// Optional flag indicating whether the exception is terminal (causes application termination). + /// + /// + /// This flag helps differentiate between unhandled exceptions that terminate the application + /// (e.g., uncaught exceptions on the main thread) and unhandled exceptions that don't + /// (e.g., unobserved task exceptions, Unity's LogException). + /// + public bool Terminal { get; set; } = true; + /// /// Optional flag indicating whether the exception is synthetic. /// @@ -138,6 +148,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteStringIfNotWhiteSpace("source", Source); writer.WriteStringIfNotWhiteSpace("help_link", HelpLink); writer.WriteBooleanIfNotNull("handled", Handled); + writer.WriteBooleanIfNotNull("terminal", Terminal); writer.WriteBooleanIfTrue("synthetic", Synthetic); writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup); writer.WriteNumberIfNotNull("exception_id", ExceptionId); @@ -158,6 +169,7 @@ public static Mechanism FromJson(JsonElement json) var source = json.GetPropertyOrNull("source")?.GetString(); var helpLink = json.GetPropertyOrNull("help_link")?.GetString(); var handled = json.GetPropertyOrNull("handled")?.GetBoolean(); + var terminal = json.GetPropertyOrNull("terminal")?.GetBoolean() ?? true; var synthetic = json.GetPropertyOrNull("synthetic")?.GetBoolean() ?? false; var isExceptionGroup = json.GetPropertyOrNull("is_exception_group")?.GetBoolean() ?? false; var exceptionId = json.GetPropertyOrNull("exception_id")?.GetInt32(); @@ -172,6 +184,7 @@ public static Mechanism FromJson(JsonElement json) Source = source, HelpLink = helpLink, Handled = handled, + Terminal = terminal, Synthetic = synthetic, IsExceptionGroup = isExceptionGroup, ExceptionId = exceptionId, @@ -183,6 +196,7 @@ public static Mechanism FromJson(JsonElement json) internal bool IsDefaultOrEmpty() => Handled is null && + Terminal is true && Synthetic == false && IsExceptionGroup == false && ExceptionId is null && diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 901cea8221..74d56b4252 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -193,7 +193,7 @@ internal bool HasUnhandledException() internal bool HasUnhandledNonTerminalException() { // Generally, an unhandled exception is considered terminal. - // Exception: If it is an unhandled exception but the mechanism key is found in NonTerminalMechanismKeys + // 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) @@ -207,7 +207,7 @@ internal bool HasUnhandledNonTerminalException() } return SentryExceptions?.Any(e => - e.Mechanism is { Handled: false } mechanism && mechanism.Data[Mechanism.TerminalKey] is false + e.Mechanism is { Handled: false, Terminal: false } ) ?? false; } diff --git a/src/Sentry/SentryExceptionExtensions.cs b/src/Sentry/SentryExceptionExtensions.cs index a4b474f165..902b21c091 100644 --- a/src/Sentry/SentryExceptionExtensions.cs +++ b/src/Sentry/SentryExceptionExtensions.cs @@ -37,7 +37,6 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de bool? handled = null, bool terminal = true) { ex.Data[Mechanism.MechanismKey] = type; - ex.Data[Mechanism.TerminalKey] = terminal; if (string.IsNullOrWhiteSpace(description)) { @@ -56,5 +55,7 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de { ex.Data[Mechanism.HandledKey] = handled; } + + ex.Data[Mechanism.TerminalKey] = terminal; } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 406a853181..f70b4afeeb 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1072,8 +1072,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 { @@ -1798,6 +1799,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 Data { get; } public string? Description { get; set; } @@ -1809,6 +1811,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; set; } 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) { } @@ -1941,5 +1944,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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 = true) { } } \ No newline at end of file diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 406a853181..f70b4afeeb 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1072,8 +1072,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 { @@ -1798,6 +1799,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 Data { get; } public string? Description { get; set; } @@ -1809,6 +1811,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; set; } 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) { } @@ -1941,5 +1944,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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 = true) { } } \ No newline at end of file diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 406a853181..f70b4afeeb 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1072,8 +1072,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 { @@ -1798,6 +1799,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 Data { get; } public string? Description { get; set; } @@ -1809,6 +1811,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; set; } 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) { } @@ -1941,5 +1944,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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 = true) { } } \ No newline at end of file diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index a79f9d7eee..cd66c33c07 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -535,7 +535,7 @@ public void CaptureEvent_TerminalUnhandledException_AbortsActiveTransaction() hub.ConfigureScope(scope => scope.Transaction = transaction); var exception = new Exception("test"); - exception.SetSentryMechanism("test", handled: false); + exception.SetSentryMechanism("test", handled: false, terminal: true); // Act hub.CaptureEvent(new SentryEvent(exception)); diff --git a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs index e5ae10a885..7da6e5db83 100644 --- a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs +++ b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs @@ -18,6 +18,7 @@ public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() Description = "mechanism description", Source = "exception source", Handled = true, + Terminal = false, HelpLink = "https://helplink", Synthetic = true, IsExceptionGroup = true, @@ -37,6 +38,7 @@ public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() "source": "exception source", "help_link": "https://helplink", "handled": true, + "terminal": false, "synthetic": true, "is_exception_group": true, "exception_id": 123, @@ -68,6 +70,8 @@ public static IEnumerable TestCases() yield return new object[] { (new Mechanism { Type = "some type" }, """{"type":"some type"}""") }; yield return new object[] { (new Mechanism { Handled = false }, """{"type":"generic","handled":false}""") }; yield return new object[] { (new Mechanism { Handled = true }, """{"type":"generic","handled":true}""") }; + yield return new object[] { (new Mechanism { Terminal = false }, """{"type":"generic","terminal":false}""") }; + yield return new object[] { (new Mechanism { Terminal = true }, """{"type":"generic","terminal":true}""") }; yield return new object[] { (new Mechanism { Synthetic = true }, """{"type":"generic","synthetic":true}""") }; yield return new object[] { (new Mechanism { HelpLink = "https://sentry.io/docs" }, """{"type":"generic","help_link":"https://sentry.io/docs"}""") }; yield return new object[] { (new Mechanism { Description = "some desc" }, """{"type":"generic","description":"some desc"}""") }; diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index 1dfb81e226..d2138e11ad 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -269,7 +269,7 @@ public void HasTerminalException_WithTerminalUnhandledException_ReturnsTrue() exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); var evt = new SentryEvent(exception); - Assert.True(evt.HasUnhandledNonTerminalException()); + Assert.True(evt.HasTerminalException()); } [Fact] @@ -279,7 +279,7 @@ public void HasTerminalException_WithNonTerminalException_ReturnsFalse() exception.SetSentryMechanism("UnobservedTaskException", handled: false, terminal: false); var evt = new SentryEvent(exception); - Assert.False(evt.HasUnhandledNonTerminalException()); + Assert.False(evt.HasTerminalException()); } [Fact] @@ -289,7 +289,7 @@ public void HasTerminalException_WithHandledException_ReturnsFalse() exception.SetSentryMechanism("test", handled: true); var evt = new SentryEvent(exception); - Assert.False(evt.HasUnhandledNonTerminalException()); + Assert.False(evt.HasTerminalException()); } [Fact] @@ -297,6 +297,6 @@ public void HasTerminalException_NoException_ReturnsFalse() { var evt = new SentryEvent(); - Assert.False(evt.HasUnhandledNonTerminalException()); + Assert.False(evt.HasTerminalException()); } } From 54d3b2f82c626a9690f876f5593a15bf59470d72 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 15 Oct 2025 10:04:19 +0000 Subject: [PATCH 03/30] Format code --- src/Sentry/SentryOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index d08014d424..4e2983cb91 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -223,7 +223,7 @@ internal IEnumerable Integrations } } } - + internal List? ExceptionFilters { get; set; } = new(); /// From f44a68796b0c8445fd2b3c7e51501e8e990ab647 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 15 Oct 2025 12:45:00 +0200 Subject: [PATCH 04/30] Made Terminal nullable --- .../UnobservedTaskExceptionIntegration.cs | 2 +- src/Sentry/Internal/MainExceptionProcessor.cs | 1 - src/Sentry/Protocol/Mechanism.cs | 18 ++++++++++--- .../Protocol/Exceptions/MechanismTests.cs | 1 + .../Sentry.Tests/Protocol/SentryEventTests.cs | 26 +++++++++---------- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs b/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs index 851351c3bf..3e3b658bc2 100644 --- a/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs +++ b/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs @@ -12,7 +12,7 @@ internal class UnobservedTaskExceptionIntegration : ISdkIntegration internal UnobservedTaskExceptionIntegration(IAppDomain? appDomain = null) => _appDomain = appDomain ?? AppDomainAdapter.Instance; - public void Register(IHub hub, SentryOptions options) + public void Register(IHub hub, SentryOptions _) { _hub = hub; _appDomain.UnobservedTaskException += Handle; diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index f3ffd498d4..a99c58367d 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -189,7 +189,6 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId if (exception.Data[Mechanism.TerminalKey] is bool terminal) { - // The mechanism terminal flag was set by an integration. mechanism.Terminal = terminal; exception.Data.Remove(Mechanism.TerminalKey); } diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 91ce77a960..3dccf1e9ca 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -83,13 +83,25 @@ public string Type /// /// Optional flag indicating whether the exception is terminal (causes application termination). + /// Only meaningful when is false. /// /// + /// /// This flag helps differentiate between unhandled exceptions that terminate the application /// (e.g., uncaught exceptions on the main thread) and unhandled exceptions that don't /// (e.g., unobserved task exceptions, Unity's LogException). + /// + /// + /// When null (default): Unhandled exceptions are assumed to be terminal.
+ /// When true: Explicitly marks the unhandled exception as terminal.
+ /// When false: Explicitly marks the unhandled exception as non-terminal. + ///
+ /// + /// This property should remain null when is true or null, + /// as terminal state is only meaningful for unhandled exceptions. + /// ///
- public bool Terminal { get; set; } = true; + public bool? Terminal { get; set; } /// /// Optional flag indicating whether the exception is synthetic. @@ -169,7 +181,7 @@ public static Mechanism FromJson(JsonElement json) var source = json.GetPropertyOrNull("source")?.GetString(); var helpLink = json.GetPropertyOrNull("help_link")?.GetString(); var handled = json.GetPropertyOrNull("handled")?.GetBoolean(); - var terminal = json.GetPropertyOrNull("terminal")?.GetBoolean() ?? true; + var terminal = json.GetPropertyOrNull("terminal")?.GetBoolean(); var synthetic = json.GetPropertyOrNull("synthetic")?.GetBoolean() ?? false; var isExceptionGroup = json.GetPropertyOrNull("is_exception_group")?.GetBoolean() ?? false; var exceptionId = json.GetPropertyOrNull("exception_id")?.GetInt32(); @@ -196,7 +208,7 @@ public static Mechanism FromJson(JsonElement json) internal bool IsDefaultOrEmpty() => Handled is null && - Terminal is true && + Terminal is null && Synthetic == false && IsExceptionGroup == false && ExceptionId is null && diff --git a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs index 7da6e5db83..79733319fe 100644 --- a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs +++ b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs @@ -72,6 +72,7 @@ public static IEnumerable TestCases() yield return new object[] { (new Mechanism { Handled = true }, """{"type":"generic","handled":true}""") }; yield return new object[] { (new Mechanism { Terminal = false }, """{"type":"generic","terminal":false}""") }; yield return new object[] { (new Mechanism { Terminal = true }, """{"type":"generic","terminal":true}""") }; + yield return new object[] { (new Mechanism { Terminal = null }, """{"type":"generic"}""") }; // null = default, not serialized yield return new object[] { (new Mechanism { Synthetic = true }, """{"type":"generic","synthetic":true}""") }; yield return new object[] { (new Mechanism { HelpLink = "https://sentry.io/docs" }, """{"type":"generic","help_link":"https://sentry.io/docs"}""") }; yield return new object[] { (new Mechanism { Description = "some desc" }, """{"type":"generic","description":"some desc"}""") }; diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index d2138e11ad..03c80692c7 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -232,16 +232,6 @@ public void HasUnhandledNonTerminalException_WithNonTerminalMechanism_ReturnsTru Assert.True(evt.HasUnhandledNonTerminalException()); } - [Fact] - public void HasUnhandledNonTerminalException_WithTerminalMechanism_ReturnsFalse() - { - var exception = new Exception("test"); - exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); - var evt = new SentryEvent(exception); - - Assert.False(evt.HasUnhandledNonTerminalException()); - } - [Fact] public void HasUnhandledNonTerminalException_WithHandledException_ReturnsFalse() { @@ -253,11 +243,19 @@ public void HasUnhandledNonTerminalException_WithHandledException_ReturnsFalse() } [Fact] - public void HasUnhandledNonTerminalException_NoMechanismKey_ReturnsFalse() + public void HasUnhandledNonTerminalException_WithNullTerminal_ReturnsFalse() { - var exception = new Exception("test"); - exception.Data[Mechanism.HandledKey] = false; - var evt = new SentryEvent(exception); + // Terminal = null means default behavior (terminal) + var evt = new SentryEvent + { + SentryExceptions = new[] + { + new SentryException + { + Mechanism = new Mechanism { Handled = false, Terminal = null } + } + } + }; Assert.False(evt.HasUnhandledNonTerminalException()); } From b6c4c58d21233cfaba3d95632c4999e19d1877e8 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 15 Oct 2025 12:47:37 +0200 Subject: [PATCH 05/30] Updated CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 719ce0d281..e54d7ab7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - SentryOptions.IsEnvironmentUser now defaults to false on MAUI. The means the User.Name will no longer be set, by default, to the name of the device ([#4606](https://github.com/getsentry/sentry-dotnet/pull/4606)) - Remove unnecessary files from SentryCocoaFramework before packing ([#4602](https://github.com/getsentry/sentry-dotnet/pull/4602)) +### 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)) From dec8f34fbdcf6a08a716759e496019ad723aff98 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 15 Oct 2025 13:49:19 +0200 Subject: [PATCH 06/30] Bump because vulnerability --- .../Sentry.AspNetCore.TestUtils.csproj | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/Sentry.AspNetCore.TestUtils/Sentry.AspNetCore.TestUtils.csproj b/test/Sentry.AspNetCore.TestUtils/Sentry.AspNetCore.TestUtils.csproj index e93320ccdf..56c81b0d79 100644 --- a/test/Sentry.AspNetCore.TestUtils/Sentry.AspNetCore.TestUtils.csproj +++ b/test/Sentry.AspNetCore.TestUtils/Sentry.AspNetCore.TestUtils.csproj @@ -36,10 +36,8 @@ - - - - + + From 16179d84f41e7b547480b28c79da5cd949a95971 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 15 Oct 2025 16:49:07 +0200 Subject: [PATCH 07/30] Conditionally add the terminal key --- src/Sentry/SentryExceptionExtensions.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Sentry/SentryExceptionExtensions.cs b/src/Sentry/SentryExceptionExtensions.cs index 902b21c091..822ad8c856 100644 --- a/src/Sentry/SentryExceptionExtensions.cs +++ b/src/Sentry/SentryExceptionExtensions.cs @@ -34,7 +34,7 @@ public static void AddSentryContext(this Exception ex, string name, IReadOnlyDic /// An optional flag indicating whether the exception was handled by the mechanism. /// An optional flag indicating whether the exception is considered terminal. public static void SetSentryMechanism(this Exception ex, string type, string? description = null, - bool? handled = null, bool terminal = true) + bool? handled = null, bool? terminal = null) { ex.Data[Mechanism.MechanismKey] = type; @@ -56,6 +56,13 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de ex.Data[Mechanism.HandledKey] = handled; } - ex.Data[Mechanism.TerminalKey] = terminal; + if (terminal == null) + { + ex.Data.Remove(Mechanism.TerminalKey); + } + else + { + ex.Data[Mechanism.TerminalKey] = terminal; + } } } From 3ecb136d5582fb52af5ca89f883f81da45669729 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 16 Oct 2025 11:46:52 +0200 Subject: [PATCH 08/30] Updated verify --- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 2 +- .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 2 +- .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 2 +- .../ApiApprovalTests.Run.Net4_8.verified.txt | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index f70b4afeeb..0f4bae7cdb 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1944,5 +1944,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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, bool terminal = true) { } + public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { } } \ No newline at end of file diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index f70b4afeeb..0f4bae7cdb 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1944,5 +1944,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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, bool terminal = true) { } + public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { } } \ No newline at end of file diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index f70b4afeeb..0f4bae7cdb 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1944,5 +1944,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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, bool terminal = true) { } + public static void SetSentryMechanism(this System.Exception ex, string type, string? description = null, bool? handled = default, bool? terminal = default) { } } \ No newline at end of file diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index e2a02fc89a..a88ad7099b 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -1048,8 +1048,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 { @@ -1769,6 +1770,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 Data { get; } public string? Description { get; set; } @@ -1780,6 +1782,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; set; } 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) { } @@ -1912,5 +1915,5 @@ public static class SentryExceptionExtensions { public static void AddSentryContext(this System.Exception ex, string name, System.Collections.Generic.IReadOnlyDictionary 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) { } } \ No newline at end of file From d9c2d730d7218e0f84d0bf563058482fba1cfe57 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 17 Oct 2025 11:51:47 +0200 Subject: [PATCH 09/30] Cache unhandled sessions instead of sending right away --- src/Sentry/GlobalSessionManager.cs | 37 +++- src/Sentry/ISessionManager.cs | 2 + src/Sentry/PersistedSessionUpdate.cs | 13 +- src/Sentry/SentryClient.cs | 4 +- src/Sentry/SentrySession.cs | 17 ++ .../Sentry.Tests/GlobalSessionManagerTests.cs | 178 ++++++++++++++++++ test/Sentry.Tests/Sentry.Tests.csproj | 2 +- 7 files changed, 244 insertions(+), 9 deletions(-) diff --git a/src/Sentry/GlobalSessionManager.cs b/src/Sentry/GlobalSessionManager.cs index 29aa66b3e1..2879e916a8 100644 --- a/src/Sentry/GlobalSessionManager.cs +++ b/src/Sentry/GlobalSessionManager.cs @@ -41,7 +41,7 @@ public GlobalSessionManager( // Take pause timestamp directly instead of referencing _lastPauseTimestamp to avoid // potential race conditions. - private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp = null) + private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp = null, bool pendingUnhandled = false) { _options.LogDebug("Persisting session (SID: '{0}') to a file.", update.Id); @@ -69,7 +69,7 @@ private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp var filePath = Path.Combine(_persistenceDirectoryPath, PersistedSessionFileName); - var persistedSessionUpdate = new PersistedSessionUpdate(update, pauseTimestamp); + var persistedSessionUpdate = new PersistedSessionUpdate(update, pauseTimestamp, pendingUnhandled); if (!_options.FileSystem.CreateFileForWriting(filePath, out var file)) { _options.LogError("Failed to persist session file."); @@ -161,7 +161,10 @@ private void DeletePersistedSession() status = _options.CrashedLastRun?.Invoke() switch { // Native crash (if native SDK enabled): + // This takes priority - escalate to Crashed even if session had pending unhandled true => SessionEndStatus.Crashed, + // Had unhandled exception but didn't crash: + _ when recoveredUpdate.PendingUnhandled => SessionEndStatus.Unhandled, // Ended while on the background, healthy session: _ when recoveredUpdate.PauseTimestamp is not null => SessionEndStatus.Exited, // Possibly out of battery, killed by OS or user, solar flare: @@ -185,9 +188,10 @@ private void DeletePersistedSession() // If there's a callback for native crashes, check that first. status); - _options.LogInfo("Recovered session: EndStatus: {0}. PauseTimestamp: {1}", + _options.LogInfo("Recovered session: EndStatus: {0}. PauseTimestamp: {1}. PendingUnhandled: {2}", sessionUpdate.EndStatus, - recoveredUpdate.PauseTimestamp); + recoveredUpdate.PauseTimestamp, + recoveredUpdate.PendingUnhandled); return sessionUpdate; } @@ -245,6 +249,14 @@ private void DeletePersistedSession() private SessionUpdate EndSession(SentrySession session, DateTimeOffset timestamp, SessionEndStatus status) { + // If session has pending unhandled exception and we're ending normally (Exited), + // preserve the Unhandled status unless explicitly overridden with a more severe status + if (session.HasPendingUnhandledException && status == SessionEndStatus.Exited) + { + status = SessionEndStatus.Unhandled; + _options.LogDebug("Ending session with Unhandled status (had pending exception)"); + } + if (status == SessionEndStatus.Crashed) { // increments the errors count, as crashed sessions should report a count of 1 per: @@ -364,4 +376,21 @@ public IReadOnlyList ResumeSession() return session.CreateUpdate(false, _clock.GetUtcNow()); } + + public void MarkSessionAsUnhandled() + { + if (_currentSession is not { } session) + { + _options.LogDebug("No active session to mark as unhandled."); + return; + } + + session.MarkUnhandledException(); + + // Persist updated session state with pending flag + var sessionUpdate = session.CreateUpdate(false, _clock.GetUtcNow()); + PersistSession(sessionUpdate, pendingUnhandled: true); + + _options.LogDebug("Session marked with pending unhandled exception."); + } } diff --git a/src/Sentry/ISessionManager.cs b/src/Sentry/ISessionManager.cs index b529a72b56..02e45e119b 100644 --- a/src/Sentry/ISessionManager.cs +++ b/src/Sentry/ISessionManager.cs @@ -17,4 +17,6 @@ internal interface ISessionManager public IReadOnlyList ResumeSession(); public SessionUpdate? ReportError(); + + public void MarkSessionAsUnhandled(); } diff --git a/src/Sentry/PersistedSessionUpdate.cs b/src/Sentry/PersistedSessionUpdate.cs index 8ca45ab03e..1caed46497 100644 --- a/src/Sentry/PersistedSessionUpdate.cs +++ b/src/Sentry/PersistedSessionUpdate.cs @@ -9,10 +9,13 @@ internal class PersistedSessionUpdate : ISentryJsonSerializable public DateTimeOffset? PauseTimestamp { get; } - public PersistedSessionUpdate(SessionUpdate update, DateTimeOffset? pauseTimestamp) + public bool PendingUnhandled { get; } + + public PersistedSessionUpdate(SessionUpdate update, DateTimeOffset? pauseTimestamp, bool pendingUnhandled = false) { Update = update; PauseTimestamp = pauseTimestamp; + PendingUnhandled = pendingUnhandled; } public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) @@ -26,6 +29,11 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteString("paused", pauseTimestamp); } + if (PendingUnhandled) + { + writer.WriteBoolean("pendingUnhandled", PendingUnhandled); + } + writer.WriteEndObject(); } @@ -33,7 +41,8 @@ public static PersistedSessionUpdate FromJson(JsonElement json) { var update = SessionUpdate.FromJson(json.GetProperty("update")); var pauseTimestamp = json.GetPropertyOrNull("paused")?.GetDateTimeOffset(); + var pendingUnhandled = json.GetPropertyOrNull("pendingUnhandled")?.GetBoolean() ?? false; - return new PersistedSessionUpdate(update, pauseTimestamp); + return new PersistedSessionUpdate(update, pauseTimestamp, pendingUnhandled); } } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index c6f43515d2..e95d300daf 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -363,8 +363,8 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) if (processedEvent.HasUnhandledNonTerminalException()) { - _options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception."); - scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled); + _options.LogDebug("Marking session as having unhandled exception (non-terminal)."); + _sessionManager.MarkSessionAsUnhandled(); } else if (processedEvent.HasUnhandledException()) { diff --git a/src/Sentry/SentrySession.cs b/src/Sentry/SentrySession.cs index 696c8f149e..636de608fe 100644 --- a/src/Sentry/SentrySession.cs +++ b/src/Sentry/SentrySession.cs @@ -36,6 +36,13 @@ public class SentrySession : ISentrySession // Start at -1 so that the first increment puts it at 0 private int _sequenceNumber = -1; + private bool _hasPendingUnhandledException; + + /// + /// Gets whether this session has an unhandled exception that hasn't been finalized yet. + /// + internal bool HasPendingUnhandledException => _hasPendingUnhandledException; + internal SentrySession( SentryId id, string? distinctId, @@ -74,6 +81,16 @@ public SentrySession(string? distinctId, string release, string? environment) /// public void ReportError() => Interlocked.Increment(ref _errorCount); + /// + /// Marks the session as having an unhandled exception without ending it. + /// This allows the session to continue and potentially escalate to Crashed if the app crashes. + /// + internal void MarkUnhandledException() + { + _hasPendingUnhandledException = true; + ReportError(); + } + internal SessionUpdate CreateUpdate( bool isInitial, DateTimeOffset timestamp, diff --git a/test/Sentry.Tests/GlobalSessionManagerTests.cs b/test/Sentry.Tests/GlobalSessionManagerTests.cs index a4e504cf92..5b3e92ecab 100644 --- a/test/Sentry.Tests/GlobalSessionManagerTests.cs +++ b/test/Sentry.Tests/GlobalSessionManagerTests.cs @@ -543,6 +543,184 @@ public void TryRecoverPersistedSession_HasRecoveredUpdateAndCrashedLastRunFailed TryRecoverPersistedSessionWithExceptionOnLastRun(); } + [Fact] + public void MarkSessionAsUnhandled_ActiveSessionExists_MarksSessionAndPersists() + { + // Arrange + var sut = _fixture.GetSut(); + sut.StartSession(); + var session = sut.CurrentSession; + + // Act + sut.MarkSessionAsUnhandled(); + + // Assert + session.Should().NotBeNull(); + session!.HasPendingUnhandledException.Should().BeTrue(); + session.ErrorCount.Should().Be(1); + + // Session should still be active (not ended) + sut.CurrentSession.Should().BeSameAs(session); + } + + [Fact] + public void MarkSessionAsUnhandled_NoActiveSession_LogsDebug() + { + // Arrange + var sut = _fixture.GetSut(); + + // Act + sut.MarkSessionAsUnhandled(); + + // Assert + _fixture.Logger.Entries.Should().Contain(e => + e.Message == "No active session to mark as unhandled." && + e.Level == SentryLevel.Debug); + } + + [Fact] + public void MarkSessionAsUnhandled_MultipleUnhandledExceptions_OnlyCountsFirstError() + { + // Arrange + var sut = _fixture.GetSut(); + sut.StartSession(); + var session = sut.CurrentSession; + + // Act + sut.MarkSessionAsUnhandled(); + sut.MarkSessionAsUnhandled(); + sut.MarkSessionAsUnhandled(); + + // Assert + session!.ErrorCount.Should().Be(1); + } + + [Fact] + public void TryRecoverPersistedSession_WithPendingUnhandledAndNoCrash_EndsAsUnhandled() + { + // Arrange + _fixture.Options.CrashedLastRun = () => false; + _fixture.PersistedSessionProvider = _ => new PersistedSessionUpdate( + AnySessionUpdate(), + pauseTimestamp: null, + pendingUnhandled: true); + + var sut = _fixture.GetSut(); + + // Act + var persistedSessionUpdate = sut.TryRecoverPersistedSession(); + + // Assert + persistedSessionUpdate.Should().NotBeNull(); + persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Unhandled); + } + + [Fact] + public void TryRecoverPersistedSession_WithPendingUnhandledAndCrash_EscalatesToCrashed() + { + // Arrange + _fixture.Options.CrashedLastRun = () => true; + _fixture.PersistedSessionProvider = _ => new PersistedSessionUpdate( + AnySessionUpdate(), + pauseTimestamp: null, + pendingUnhandled: true); + + var sut = _fixture.GetSut(); + + // Act + var persistedSessionUpdate = sut.TryRecoverPersistedSession(); + + // Assert + persistedSessionUpdate.Should().NotBeNull(); + persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed); + + _fixture.Logger.Entries.Should().Contain(e => + e.Message.Contains("PendingUnhandled: True") && + e.Level == SentryLevel.Info); + } + + [Fact] + public void TryRecoverPersistedSession_WithPendingUnhandledAndPauseTimestamp_EscalatesToCrashedIfCrashed() + { + // Arrange - Session was paused AND had pending unhandled, then crashed + _fixture.Options.CrashedLastRun = () => true; + var pausedTimestamp = DateTimeOffset.Now; + _fixture.PersistedSessionProvider = _ => new PersistedSessionUpdate( + AnySessionUpdate(), + pausedTimestamp, + pendingUnhandled: true); + + var sut = _fixture.GetSut(); + + // Act + var persistedSessionUpdate = sut.TryRecoverPersistedSession(); + + // Assert + // Crash takes priority over all other end statuses + persistedSessionUpdate.Should().NotBeNull(); + persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed); + } + + [Fact] + public void EndSession_WithPendingUnhandledException_PreservesUnhandledStatus() + { + // Arrange + var sut = _fixture.GetSut(); + sut.StartSession(); + sut.MarkSessionAsUnhandled(); + + // Act - Try to end normally with Exited status + var sessionUpdate = sut.EndSession(SessionEndStatus.Exited); + + // Assert - Should be overridden to Unhandled + sessionUpdate.Should().NotBeNull(); + sessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Unhandled); + sessionUpdate.ErrorCount.Should().Be(1); + } + + [Fact] + public void EndSession_WithPendingUnhandledAndCrashedStatus_UsesCrashedStatus() + { + // Arrange + var sut = _fixture.GetSut(); + sut.StartSession(); + sut.MarkSessionAsUnhandled(); + + // Act - Explicitly end with Crashed status + var sessionUpdate = sut.EndSession(SessionEndStatus.Crashed); + + // Assert - Crashed status takes priority + sessionUpdate.Should().NotBeNull(); + sessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed); + sessionUpdate.ErrorCount.Should().Be(1); + } + + [Fact] + public void SessionEscalation_CompleteFlow_UnhandledThenCrash() + { + // Arrange - Simulate complete flow + var sut = _fixture.GetSut(); + sut.StartSession(); + var originalSessionId = sut.CurrentSession!.Id; + + // Act 1: Mark as unhandled (game encounters exception but continues) + sut.MarkSessionAsUnhandled(); + + // Assert: Session still active with pending flag + sut.CurrentSession.Should().NotBeNull(); + sut.CurrentSession!.Id.Should().Be(originalSessionId); + sut.CurrentSession.HasPendingUnhandledException.Should().BeTrue(); + + // Act 2: Recover on next launch with crash detected + _fixture.Options.CrashedLastRun = () => true; + var recovered = sut.TryRecoverPersistedSession(); + + // Assert: Session escalated from Unhandled to Crashed + recovered.Should().NotBeNull(); + recovered!.EndStatus.Should().Be(SessionEndStatus.Crashed); + recovered.Id.Should().Be(originalSessionId); + } + // A session update (of which the state doesn't matter for the test): private static SessionUpdate AnySessionUpdate() => new( diff --git a/test/Sentry.Tests/Sentry.Tests.csproj b/test/Sentry.Tests/Sentry.Tests.csproj index 1a6e4f3ef8..9802ef9f96 100644 --- a/test/Sentry.Tests/Sentry.Tests.csproj +++ b/test/Sentry.Tests/Sentry.Tests.csproj @@ -1,7 +1,7 @@  - $(CurrentTfms) + $(CurrentTfms);net48 $(TargetFrameworks);$(LatestAndroidTfm);$(PreviousAndroidTfm) $(TargetFrameworks);$(LatestIosTfm);$(PreviousIosTfm) $(TargetFrameworks);$(LatestMacCatalystTfm);$(PreviousMacCatalystTfm) From fac1ab452b2ecf1de11915ed7a760527131e8623 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 17 Oct 2025 12:48:43 +0200 Subject: [PATCH 10/30] Moved 'terminal' into data bag --- src/Sentry/Internal/MainExceptionProcessor.cs | 6 --- src/Sentry/Protocol/Mechanism.cs | 26 ------------- src/Sentry/SentryEvent.cs | 4 +- ...iApprovalTests.Run.DotNet10_0.verified.txt | 1 - ...piApprovalTests.Run.DotNet8_0.verified.txt | 1 - ...piApprovalTests.Run.DotNet9_0.verified.txt | 1 - .../Internals/MainExceptionProcessorTests.cs | 34 +++++++++++++++++ .../Protocol/Exceptions/MechanismTests.cs | 38 ++++++++++++++++--- .../Sentry.Tests/Protocol/SentryEventTests.cs | 24 ++---------- 9 files changed, 73 insertions(+), 62 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index a99c58367d..b80ab1ea94 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -187,12 +187,6 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId mechanism.Handled = null; } - if (exception.Data[Mechanism.TerminalKey] is bool terminal) - { - mechanism.Terminal = terminal; - exception.Data.Remove(Mechanism.TerminalKey); - } - if (exception.Data[Mechanism.MechanismKey] is string mechanismType) { mechanism.Type = mechanismType; diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 3dccf1e9ca..480d47d99d 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -81,28 +81,6 @@ public string Type ///
public bool? Handled { get; set; } - /// - /// Optional flag indicating whether the exception is terminal (causes application termination). - /// Only meaningful when is false. - /// - /// - /// - /// This flag helps differentiate between unhandled exceptions that terminate the application - /// (e.g., uncaught exceptions on the main thread) and unhandled exceptions that don't - /// (e.g., unobserved task exceptions, Unity's LogException). - /// - /// - /// When null (default): Unhandled exceptions are assumed to be terminal.
- /// When true: Explicitly marks the unhandled exception as terminal.
- /// When false: Explicitly marks the unhandled exception as non-terminal. - ///
- /// - /// This property should remain null when is true or null, - /// as terminal state is only meaningful for unhandled exceptions. - /// - ///
- public bool? Terminal { get; set; } - /// /// Optional flag indicating whether the exception is synthetic. /// @@ -160,7 +138,6 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteStringIfNotWhiteSpace("source", Source); writer.WriteStringIfNotWhiteSpace("help_link", HelpLink); writer.WriteBooleanIfNotNull("handled", Handled); - writer.WriteBooleanIfNotNull("terminal", Terminal); writer.WriteBooleanIfTrue("synthetic", Synthetic); writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup); writer.WriteNumberIfNotNull("exception_id", ExceptionId); @@ -181,7 +158,6 @@ public static Mechanism FromJson(JsonElement json) var source = json.GetPropertyOrNull("source")?.GetString(); var helpLink = json.GetPropertyOrNull("help_link")?.GetString(); var handled = json.GetPropertyOrNull("handled")?.GetBoolean(); - var terminal = json.GetPropertyOrNull("terminal")?.GetBoolean(); var synthetic = json.GetPropertyOrNull("synthetic")?.GetBoolean() ?? false; var isExceptionGroup = json.GetPropertyOrNull("is_exception_group")?.GetBoolean() ?? false; var exceptionId = json.GetPropertyOrNull("exception_id")?.GetInt32(); @@ -196,7 +172,6 @@ public static Mechanism FromJson(JsonElement json) Source = source, HelpLink = helpLink, Handled = handled, - Terminal = terminal, Synthetic = synthetic, IsExceptionGroup = isExceptionGroup, ExceptionId = exceptionId, @@ -208,7 +183,6 @@ public static Mechanism FromJson(JsonElement json) internal bool IsDefaultOrEmpty() => Handled is null && - Terminal is null && Synthetic == false && IsExceptionGroup == false && ExceptionId is null && diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 74d56b4252..7180106cdd 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -207,7 +207,9 @@ internal bool HasUnhandledNonTerminalException() } return SentryExceptions?.Any(e => - e.Mechanism is { Handled: false, Terminal: false } + e.Mechanism is { Handled: false } && + e.Mechanism.Data.TryGetValue(Mechanism.TerminalKey, out var terminal) && + terminal is false ) ?? false; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 4a85f9b42a..8c7bb79924 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1786,7 +1786,6 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } - public bool? Terminal { get; set; } 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) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 4a85f9b42a..8c7bb79924 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1786,7 +1786,6 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } - public bool? Terminal { get; set; } 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) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 4a85f9b42a..8c7bb79924 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1786,7 +1786,6 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } - public bool? Terminal { get; set; } 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) { } diff --git a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs index f5dedc0b4e..55f660049c 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs @@ -103,6 +103,40 @@ public void Process_ExceptionWith_HandledTrue_WhenCaught() Assert.Single(evt.SentryExceptions, p => p.Mechanism?.Handled == true); } + [Fact] + public void Process_ExceptionWith_TerminalTrue_StoresInMechanismData() + { + var sut = _fixture.GetSut(); + var evt = new SentryEvent(); + var exp = new Exception(); + + exp.Data.Add(Mechanism.TerminalKey, true); + + sut.Process(exp, evt); + + Assert.NotNull(evt.SentryExceptions); + var sentryException = evt.SentryExceptions.Single(); + Assert.True(sentryException.Mechanism?.Data.ContainsKey(Mechanism.TerminalKey)); + Assert.Equal(true, sentryException.Mechanism?.Data[Mechanism.TerminalKey]); + } + + [Fact] + public void Process_ExceptionWith_TerminalFalse_StoresInMechanismData() + { + var sut = _fixture.GetSut(); + var evt = new SentryEvent(); + var exp = new Exception(); + + exp.Data.Add(Mechanism.TerminalKey, false); + + sut.Process(exp, evt); + + Assert.NotNull(evt.SentryExceptions); + var sentryException = evt.SentryExceptions.Single(); + Assert.True(sentryException.Mechanism?.Data.ContainsKey(Mechanism.TerminalKey)); + Assert.Equal(false, sentryException.Mechanism?.Data[Mechanism.TerminalKey]); + } + [Fact] public void CreateSentryException_DataHasObjectAsKey_ItemIgnored() { diff --git a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs index 79733319fe..1db5a9b2d8 100644 --- a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs +++ b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs @@ -18,7 +18,6 @@ public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() Description = "mechanism description", Source = "exception source", Handled = true, - Terminal = false, HelpLink = "https://helplink", Synthetic = true, IsExceptionGroup = true, @@ -38,7 +37,6 @@ public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() "source": "exception source", "help_link": "https://helplink", "handled": true, - "terminal": false, "synthetic": true, "is_exception_group": true, "exception_id": 123, @@ -70,13 +68,43 @@ public static IEnumerable TestCases() yield return new object[] { (new Mechanism { Type = "some type" }, """{"type":"some type"}""") }; yield return new object[] { (new Mechanism { Handled = false }, """{"type":"generic","handled":false}""") }; yield return new object[] { (new Mechanism { Handled = true }, """{"type":"generic","handled":true}""") }; - yield return new object[] { (new Mechanism { Terminal = false }, """{"type":"generic","terminal":false}""") }; - yield return new object[] { (new Mechanism { Terminal = true }, """{"type":"generic","terminal":true}""") }; - yield return new object[] { (new Mechanism { Terminal = null }, """{"type":"generic"}""") }; // null = default, not serialized yield return new object[] { (new Mechanism { Synthetic = true }, """{"type":"generic","synthetic":true}""") }; yield return new object[] { (new Mechanism { HelpLink = "https://sentry.io/docs" }, """{"type":"generic","help_link":"https://sentry.io/docs"}""") }; yield return new object[] { (new Mechanism { Description = "some desc" }, """{"type":"generic","description":"some desc"}""") }; yield return new object[] { (new Mechanism { Data = { new KeyValuePair("data-key", "data-value") } }, """{"type":"generic","data":{"data-key":"data-value"}}""") }; yield return new object[] { (new Mechanism { Meta = { new KeyValuePair("meta-key", "meta-value") } }, """{"type":"generic","meta":{"meta-key":"meta-value"}}""") }; + yield return new object[] { (new Mechanism { Data = { new KeyValuePair(Mechanism.TerminalKey, true) } }, """{"type":"generic","data":{"Sentry:Terminal":true}}""") }; + yield return new object[] { (new Mechanism { Data = { new KeyValuePair(Mechanism.TerminalKey, false) } }, """{"type":"generic","data":{"Sentry:Terminal":false}}""") }; + } + + [Fact] + public void SetSentryMechanism_WithTerminalTrue_StoresInExceptionData() + { + var exception = new Exception(); + exception.SetSentryMechanism("test", handled: false, terminal: true); + + Assert.True(exception.Data.Contains(Mechanism.TerminalKey)); + Assert.Equal(true, exception.Data[Mechanism.TerminalKey]); + } + + [Fact] + public void SetSentryMechanism_WithTerminalFalse_StoresInExceptionData() + { + var exception = new Exception(); + exception.SetSentryMechanism("test", handled: false, terminal: false); + + Assert.True(exception.Data.Contains(Mechanism.TerminalKey)); + Assert.Equal(false, exception.Data[Mechanism.TerminalKey]); + } + + [Fact] + public void SetSentryMechanism_WithTerminalNull_RemovesFromExceptionData() + { + var exception = new Exception(); + exception.Data[Mechanism.TerminalKey] = true; + + exception.SetSentryMechanism("test", handled: false, terminal: null); + + Assert.False(exception.Data.Contains(Mechanism.TerminalKey)); } } diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index 03c80692c7..b75b08cdc0 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -193,13 +193,7 @@ public void HasUnhandledException_WithSentryExceptions_Unhandled_ReturnsTrue() { var evt = new SentryEvent { - SentryExceptions = new[] - { - new SentryException - { - Mechanism = new Mechanism { Handled = false } - } - } + SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] }; Assert.True(evt.HasUnhandledException()); @@ -210,13 +204,7 @@ public void HasUnhandledException_WithSentryExceptions_Handled_ReturnsFalse() { var evt = new SentryEvent { - SentryExceptions = new[] - { - new SentryException - { - Mechanism = new Mechanism { Handled = true } - } - } + SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = true } }] }; Assert.False(evt.HasUnhandledException()); @@ -248,13 +236,7 @@ public void HasUnhandledNonTerminalException_WithNullTerminal_ReturnsFalse() // Terminal = null means default behavior (terminal) var evt = new SentryEvent { - SentryExceptions = new[] - { - new SentryException - { - Mechanism = new Mechanism { Handled = false, Terminal = null } - } - } + SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] }; Assert.False(evt.HasUnhandledNonTerminalException()); From 7a590348b7b913d46b8a7339e44bd295b774aa23 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 17 Oct 2025 15:28:59 +0200 Subject: [PATCH 11/30] Keep the key --- src/Sentry/Protocol/Mechanism.cs | 5 +++-- test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 480d47d99d..8ff6b2f17b 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -30,9 +30,10 @@ public sealed class Mechanism : ISentryJsonSerializable public static readonly string DescriptionKey = "Sentry:Description"; /// - /// Key found inside of Exception.Data describing whether the exception is considered terminal + /// Key found inside of Exception.Data describing whether the exception is considered terminal. /// - public static readonly string TerminalKey = "Sentry:Terminal"; + /// It's not prefixed with 'Sentry' so the MainExceptionProcessor does not remove it. + public static readonly string TerminalKey = "Terminal"; internal Dictionary? InternalData { get; private set; } diff --git a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs index 1db5a9b2d8..1f310a859d 100644 --- a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs +++ b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs @@ -73,8 +73,6 @@ public static IEnumerable TestCases() yield return new object[] { (new Mechanism { Description = "some desc" }, """{"type":"generic","description":"some desc"}""") }; yield return new object[] { (new Mechanism { Data = { new KeyValuePair("data-key", "data-value") } }, """{"type":"generic","data":{"data-key":"data-value"}}""") }; yield return new object[] { (new Mechanism { Meta = { new KeyValuePair("meta-key", "meta-value") } }, """{"type":"generic","meta":{"meta-key":"meta-value"}}""") }; - yield return new object[] { (new Mechanism { Data = { new KeyValuePair(Mechanism.TerminalKey, true) } }, """{"type":"generic","data":{"Sentry:Terminal":true}}""") }; - yield return new object[] { (new Mechanism { Data = { new KeyValuePair(Mechanism.TerminalKey, false) } }, """{"type":"generic","data":{"Sentry:Terminal":false}}""") }; } [Fact] From 660714eae6bb39c32058ff434b46d4885c449a4d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 17 Oct 2025 15:47:25 +0200 Subject: [PATCH 12/30] . --- src/Sentry/GlobalSessionManager.cs | 12 ++++-------- src/Sentry/SentryClient.cs | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Sentry/GlobalSessionManager.cs b/src/Sentry/GlobalSessionManager.cs index 2879e916a8..61ad1f49bb 100644 --- a/src/Sentry/GlobalSessionManager.cs +++ b/src/Sentry/GlobalSessionManager.cs @@ -249,12 +249,11 @@ private void DeletePersistedSession() private SessionUpdate EndSession(SentrySession session, DateTimeOffset timestamp, SessionEndStatus status) { - // If session has pending unhandled exception and we're ending normally (Exited), - // preserve the Unhandled status unless explicitly overridden with a more severe status - if (session.HasPendingUnhandledException && status == SessionEndStatus.Exited) + // If we're ending as 'Exited' but he session has a pending 'Unhandled', end as 'Unhandled' + if (status == SessionEndStatus.Exited && session.HasPendingUnhandledException) { status = SessionEndStatus.Unhandled; - _options.LogDebug("Ending session with Unhandled status (had pending exception)"); + _options.LogDebug("Session ended as 'Unhandled' due to pending status."); } if (status == SessionEndStatus.Crashed) @@ -381,16 +380,13 @@ public void MarkSessionAsUnhandled() { if (_currentSession is not { } session) { - _options.LogDebug("No active session to mark as unhandled."); + _options.LogDebug("There is no session active. Skipping marking session as unhandled."); return; } session.MarkUnhandledException(); - // Persist updated session state with pending flag var sessionUpdate = session.CreateUpdate(false, _clock.GetUtcNow()); PersistSession(sessionUpdate, pendingUnhandled: true); - - _options.LogDebug("Session marked with pending unhandled exception."); } } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 3c6f38bc49..c6adf2bf41 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -349,7 +349,7 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) if (processedEvent.HasUnhandledNonTerminalException()) { - _options.LogDebug("Marking session as having unhandled exception (non-terminal)."); + _options.LogDebug("Marking session as 'Unhandled', due to unhandled exception."); _sessionManager.MarkSessionAsUnhandled(); } else if (processedEvent.HasUnhandledException()) From 0c47b3f6dc7b02f5a821149d992db3ea995f801f Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 10:30:05 +0200 Subject: [PATCH 13/30] Updated verify for net48 --- test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index f83c9fcea0..348cb52e24 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -1757,7 +1757,6 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } - public bool? Terminal { get; set; } 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) { } From 04d888932eb0381573de5f14bd41aff7765a75c6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 12:17:11 +0200 Subject: [PATCH 14/30] Wrap exception type with enum --- src/Sentry/Internal/Hub.cs | 3 +- src/Sentry/SentryClient.cs | 33 ++++---- src/Sentry/SentryEvent.cs | 36 ++++++-- .../Sentry.Tests/Protocol/SentryEventTests.cs | 83 +++++-------------- 4 files changed, 73 insertions(+), 82 deletions(-) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index faaa83a4f5..ea3c928f92 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -573,7 +573,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.Unhandled + && 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. diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 7396d8005b..89e19ae98f 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -347,22 +347,23 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return SentryId.Empty; // Dropped by BeforeSend callback } - if (processedEvent.HasUnhandledNonTerminalException()) - { - _options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception."); - scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled); - } - else if (processedEvent.HasUnhandledException()) - { - _options.LogDebug("Ending session as 'Crashed', due to terminal unhandled exception."); - scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed); - } - else if (processedEvent.HasException()) - { - // Event contains a handled exception -> Report error - // (this might return null if the session has already reported errors before) - _options.LogDebug("Updating session by reporting an error."); - 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.Unhandled: + _options.LogDebug("Ending session as 'Crashed', due to terminal 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) diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 7180106cdd..6ddae3824e 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -178,9 +178,37 @@ public IReadOnlyList Fingerprint /// public IReadOnlyDictionary Tags => _tags ??= new Dictionary(); - internal bool HasException() => Exception is not null || SentryExceptions?.Any() == true; + internal enum ExceptionType + { + None, + Handled, + Unhandled, + UnhandledNonTerminal + } + + internal ExceptionType GetExceptionType() + { + if (!HasException()) + { + return ExceptionType.None; + } + + if (HasUnhandledNonTerminalException()) + { + return ExceptionType.UnhandledNonTerminal; + } + + if (HasUnhandledException()) + { + return ExceptionType.Unhandled; + } - internal bool HasUnhandledException() + return ExceptionType.Handled; + } + + private bool HasException() => Exception is not null || SentryExceptions?.Any() == true; + + private bool HasUnhandledException() { if (Exception?.Data[Mechanism.HandledKey] is false) { @@ -190,7 +218,7 @@ internal bool HasUnhandledException() return SentryExceptions?.Any(e => e.Mechanism is { Handled: false }) ?? false; } - internal bool HasUnhandledNonTerminalException() + 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. @@ -213,8 +241,6 @@ terminal is false ) ?? false; } - internal bool HasTerminalException() => HasUnhandledException() && !HasUnhandledNonTerminalException(); - internal DynamicSamplingContext? DynamicSamplingContext { get; set; } /// diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index b75b08cdc0..dbbe483550 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -169,114 +169,77 @@ public void Redact_Redacts_Urls() } [Fact] - public void HasUnhandledException_WithUnhandledException_ReturnsTrue() + public void GetExceptionType_NoException_ReturnsNone() { - var exception = new Exception("test"); - exception.SetSentryMechanism("test", handled: false); - var evt = new SentryEvent(exception); + var evt = new SentryEvent(); - Assert.True(evt.HasUnhandledException()); + Assert.Equal(SentryEvent.ExceptionType.None, evt.GetExceptionType()); } [Fact] - public void HasUnhandledException_WithHandledException_ReturnsFalse() + public void GetExceptionType_HandledException_ReturnsHandled() { var exception = new Exception("test"); exception.SetSentryMechanism("test", handled: true); var evt = new SentryEvent(exception); - Assert.False(evt.HasUnhandledException()); - } - - [Fact] - public void HasUnhandledException_WithSentryExceptions_Unhandled_ReturnsTrue() - { - var evt = new SentryEvent - { - SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] - }; - - Assert.True(evt.HasUnhandledException()); + Assert.Equal(SentryEvent.ExceptionType.Handled, evt.GetExceptionType()); } [Fact] - public void HasUnhandledException_WithSentryExceptions_Handled_ReturnsFalse() + public void GetExceptionType_HandledExceptionViaSentryExceptions_ReturnsHandled() { var evt = new SentryEvent { SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = true } }] }; - Assert.False(evt.HasUnhandledException()); - } - - [Fact] - public void HasUnhandledNonTerminalException_WithNonTerminalMechanism_ReturnsTrue() - { - var exception = new Exception("test"); - exception.SetSentryMechanism("UnobservedTaskException", handled: false, terminal: false); - var evt = new SentryEvent(exception); - - Assert.True(evt.HasUnhandledNonTerminalException()); + Assert.Equal(SentryEvent.ExceptionType.Handled, evt.GetExceptionType()); } [Fact] - public void HasUnhandledNonTerminalException_WithHandledException_ReturnsFalse() + public void GetExceptionType_UnhandledTerminalException_ReturnsUnhandled() { var exception = new Exception("test"); - exception.SetSentryMechanism("UnobservedTaskException", handled: true); + exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); var evt = new SentryEvent(exception); - Assert.False(evt.HasUnhandledNonTerminalException()); + Assert.Equal(SentryEvent.ExceptionType.Unhandled, evt.GetExceptionType()); } [Fact] - public void HasUnhandledNonTerminalException_WithNullTerminal_ReturnsFalse() + public void GetExceptionType_UnhandledTerminalExceptionViaSentryExceptions_ReturnsUnhandled() { - // Terminal = null means default behavior (terminal) + // Terminal = null or true means default behavior (terminal) var evt = new SentryEvent { SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] }; - Assert.False(evt.HasUnhandledNonTerminalException()); - } - - [Fact] - public void HasTerminalException_WithTerminalUnhandledException_ReturnsTrue() - { - var exception = new Exception("test"); - exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); - var evt = new SentryEvent(exception); - - Assert.True(evt.HasTerminalException()); + Assert.Equal(SentryEvent.ExceptionType.Unhandled, evt.GetExceptionType()); } [Fact] - public void HasTerminalException_WithNonTerminalException_ReturnsFalse() + public void GetExceptionType_UnhandledNonTerminalException_ReturnsUnhandledNonTerminal() { var exception = new Exception("test"); exception.SetSentryMechanism("UnobservedTaskException", handled: false, terminal: false); var evt = new SentryEvent(exception); - Assert.False(evt.HasTerminalException()); + Assert.Equal(SentryEvent.ExceptionType.UnhandledNonTerminal, evt.GetExceptionType()); } [Fact] - public void HasTerminalException_WithHandledException_ReturnsFalse() + public void GetExceptionType_UnhandledNonTerminalExceptionViaSentryExceptions_ReturnsUnhandledNonTerminal() { - var exception = new Exception("test"); - exception.SetSentryMechanism("test", handled: true); - var evt = new SentryEvent(exception); + var mechanism = new Mechanism { Handled = false }; + mechanism.Data[Mechanism.TerminalKey] = false; - Assert.False(evt.HasTerminalException()); - } - - [Fact] - public void HasTerminalException_NoException_ReturnsFalse() - { - var evt = new SentryEvent(); + var evt = new SentryEvent + { + SentryExceptions = [new SentryException { Mechanism = mechanism }] + }; - Assert.False(evt.HasTerminalException()); + Assert.Equal(SentryEvent.ExceptionType.UnhandledNonTerminal, evt.GetExceptionType()); } } From 4c24f516947648648717f6fb097e6534be177b11 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 12:20:59 +0200 Subject: [PATCH 15/30] Logging --- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryClient.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index ea3c928f92..5425b20628 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -578,7 +578,7 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope) { // 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. - _options.LogDebug("Ending transaction as Aborted, due to terminal unhandled exception."); + _options.LogDebug("Ending transaction as Aborted, due to unhandled exception."); transaction.Finish(SpanStatus.Aborted); } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 89e19ae98f..d8cf1bb842 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -356,7 +356,7 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) break; case SentryEvent.ExceptionType.Unhandled: - _options.LogDebug("Ending session as 'Crashed', due to terminal unhandled exception."); + _options.LogDebug("Ending session as 'Crashed', due to unhandled exception."); scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed); break; From bbefdd3202e8a5de7f295a5e32215a1eaf4b5772 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 13:00:26 +0200 Subject: [PATCH 16/30] Prevent Mechanism.TerminalKey from being serialized --- src/Sentry/Internal/MainExceptionProcessor.cs | 12 ++++++++++++ src/Sentry/Protocol/Mechanism.cs | 15 ++++++++++++--- .../Internals/MainExceptionProcessorTests.cs | 4 ++-- test/Sentry.Tests/Protocol/SentryEventTests.cs | 1 - 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index b80ab1ea94..86d970a8ed 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -122,6 +122,11 @@ value is string stringValue && sentryEvent.Contexts[key[ExceptionDataContextKey.Length..]] = value; keysToRemove.Add(key); } + else if (key.Equals(Mechanism.TerminalKey, StringComparison.OrdinalIgnoreCase)) + { + // Terminal is SDK-internal only, used for session tracking. Don't send to Sentry. + // Skip it - will be removed during serialization in Mechanism.WriteTo() + } else if (key.StartsWith(ExceptionDataKeyPrefix, StringComparison.OrdinalIgnoreCase)) { sentryEvent.SetExtra($"Exception[{i}][{key}]", value); @@ -202,6 +207,13 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId // Add HResult to mechanism data before adding exception data, so that it can be overridden. mechanism.Data["HResult"] = $"0x{exception.HResult:X8}"; + // Copy Terminal flag for internal processing, but remove it from Exception.Data + if (exception.Data[Mechanism.TerminalKey] is bool terminal) + { + mechanism.Data[Mechanism.TerminalKey] = terminal; + exception.Data.Remove(Mechanism.TerminalKey); + } + // Copy remaining exception data to mechanism data. foreach (var key in exception.Data.Keys.OfType()) { diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 8ff6b2f17b..0987aa74eb 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -32,8 +32,10 @@ public sealed class Mechanism : ISentryJsonSerializable /// /// Key found inside of Exception.Data describing whether the exception is considered terminal. /// - /// It's not prefixed with 'Sentry' so the MainExceptionProcessor does not remove it. - public static readonly string TerminalKey = "Terminal"; + /// + /// This is an SDK-internal flag used for session tracking and is not sent to Sentry servers. + /// + public static readonly string TerminalKey = "Sentry:Terminal"; internal Dictionary? InternalData { get; private set; } @@ -143,7 +145,14 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup); writer.WriteNumberIfNotNull("exception_id", ExceptionId); writer.WriteNumberIfNotNull("parent_id", ParentId); - writer.WriteDictionaryIfNotEmpty("data", InternalData!, logger); + + // Filter out Terminal flag from Data before serialization (SDK-internal only) + var dataToSerialize = InternalData?.Count > 0 + ? InternalData.Where(kvp => !kvp.Key.Equals(TerminalKey, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + : null; + writer.WriteDictionaryIfNotEmpty("data", dataToSerialize!, logger); + writer.WriteDictionaryIfNotEmpty("meta", InternalMeta!, logger); writer.WriteEndObject(); diff --git a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs index 55f660049c..1faec7176f 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs @@ -110,7 +110,7 @@ public void Process_ExceptionWith_TerminalTrue_StoresInMechanismData() var evt = new SentryEvent(); var exp = new Exception(); - exp.Data.Add(Mechanism.TerminalKey, true); + exp.SetSentryMechanism("TestException", terminal: true); sut.Process(exp, evt); @@ -127,7 +127,7 @@ public void Process_ExceptionWith_TerminalFalse_StoresInMechanismData() var evt = new SentryEvent(); var exp = new Exception(); - exp.Data.Add(Mechanism.TerminalKey, false); + exp.SetSentryMechanism("TestException", terminal: false); sut.Process(exp, evt); diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index dbbe483550..ed8f7ed75a 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -210,7 +210,6 @@ public void GetExceptionType_UnhandledTerminalException_ReturnsUnhandled() [Fact] public void GetExceptionType_UnhandledTerminalExceptionViaSentryExceptions_ReturnsUnhandled() { - // Terminal = null or true means default behavior (terminal) var evt = new SentryEvent { SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] From fe4e9157a49f3c42f21be2563e678ed6af70152c Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 13:12:45 +0200 Subject: [PATCH 17/30] Filter Terminal in WriteTo --- src/Sentry/Internal/MainExceptionProcessor.cs | 4 ++-- src/Sentry/Protocol/Mechanism.cs | 21 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index 86d970a8ed..ce6326b491 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -124,8 +124,8 @@ value is string stringValue && } else if (key.Equals(Mechanism.TerminalKey, StringComparison.OrdinalIgnoreCase)) { - // Terminal is SDK-internal only, used for session tracking. Don't send to Sentry. - // Skip it - will be removed during serialization in Mechanism.WriteTo() + // Terminal is SDK-internal only, used for session updates. Don't send to Sentry. + // It will be except from serialization in Mechanism.WriteTo() as well } else if (key.StartsWith(ExceptionDataKeyPrefix, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 0987aa74eb..4da396e11b 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -147,11 +147,22 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteNumberIfNotNull("parent_id", ParentId); // Filter out Terminal flag from Data before serialization (SDK-internal only) - var dataToSerialize = InternalData?.Count > 0 - ? InternalData.Where(kvp => !kvp.Key.Equals(TerminalKey, StringComparison.OrdinalIgnoreCase)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - : null; - writer.WriteDictionaryIfNotEmpty("data", dataToSerialize!, logger); + // Only create a filtered dictionary if Terminal actually exists + Dictionary? dataToSerialize = null; + if (InternalData?.Count > 0) + { + if (InternalData.ContainsKey(TerminalKey)) + { + dataToSerialize = InternalData + .Where(kvp => !kvp.Key.Equals(TerminalKey, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + else + { + dataToSerialize = InternalData; + } + } + writer.WriteDictionaryIfNotEmpty("data", dataToSerialize, logger); writer.WriteDictionaryIfNotEmpty("meta", InternalMeta!, logger); From 31416f9cf45d975184783b40e38b71ddb75d7d2d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 13:40:12 +0200 Subject: [PATCH 18/30] Make TerminalKey top level but don't serialize --- src/Sentry/Internal/MainExceptionProcessor.cs | 14 +++------ src/Sentry/Protocol/Mechanism.cs | 30 +++++++------------ src/Sentry/SentryEvent.cs | 4 +-- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index ce6326b491..3a5d524df4 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -122,11 +122,6 @@ value is string stringValue && sentryEvent.Contexts[key[ExceptionDataContextKey.Length..]] = value; keysToRemove.Add(key); } - else if (key.Equals(Mechanism.TerminalKey, StringComparison.OrdinalIgnoreCase)) - { - // Terminal is SDK-internal only, used for session updates. Don't send to Sentry. - // It will be except from serialization in Mechanism.WriteTo() as well - } else if (key.StartsWith(ExceptionDataKeyPrefix, StringComparison.OrdinalIgnoreCase)) { sentryEvent.SetExtra($"Exception[{i}][{key}]", value); @@ -204,16 +199,15 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId exception.Data.Remove(Mechanism.DescriptionKey); } - // Add HResult to mechanism data before adding exception data, so that it can be overridden. - mechanism.Data["HResult"] = $"0x{exception.HResult:X8}"; - - // Copy Terminal flag for internal processing, but remove it from Exception.Data if (exception.Data[Mechanism.TerminalKey] is bool terminal) { - mechanism.Data[Mechanism.TerminalKey] = 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}"; + // Copy remaining exception data to mechanism data. foreach (var key in exception.Data.Keys.OfType()) { diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index 4da396e11b..3b37ab5b64 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -84,6 +84,15 @@ public string Type /// public bool? Handled { get; set; } + /// + /// 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). + /// + /// + /// This is an SDK-internal flag used for session tracking and is not serialized to Sentry servers. + /// + public bool? Terminal { get; internal set; } + /// /// Optional flag indicating whether the exception is synthetic. /// @@ -141,29 +150,12 @@ 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); writer.WriteNumberIfNotNull("parent_id", ParentId); - - // Filter out Terminal flag from Data before serialization (SDK-internal only) - // Only create a filtered dictionary if Terminal actually exists - Dictionary? dataToSerialize = null; - if (InternalData?.Count > 0) - { - if (InternalData.ContainsKey(TerminalKey)) - { - dataToSerialize = InternalData - .Where(kvp => !kvp.Key.Equals(TerminalKey, StringComparison.OrdinalIgnoreCase)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - else - { - dataToSerialize = InternalData; - } - } - writer.WriteDictionaryIfNotEmpty("data", dataToSerialize, logger); - + writer.WriteDictionaryIfNotEmpty("data", InternalData!, logger); writer.WriteDictionaryIfNotEmpty("meta", InternalMeta!, logger); writer.WriteEndObject(); diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 6ddae3824e..662b60d1ad 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -235,9 +235,7 @@ private bool HasUnhandledNonTerminalException() } return SentryExceptions?.Any(e => - e.Mechanism is { Handled: false } && - e.Mechanism.Data.TryGetValue(Mechanism.TerminalKey, out var terminal) && - terminal is false + e.Mechanism is { Handled: false, Terminal: false } ) ?? false; } From 1ad719c67bac8a77870c86e2a0b946ca3fcdf65d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 13:57:06 +0200 Subject: [PATCH 19/30] Fixed tests --- .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 1 + .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 1 + .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 1 + test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs | 7 +++---- test/Sentry.Tests/Protocol/SentryEventTests.cs | 3 +-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 8c7bb79924..f91c5084b6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1786,6 +1786,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) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 8c7bb79924..f91c5084b6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1786,6 +1786,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) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 8c7bb79924..f91c5084b6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1786,6 +1786,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) { } diff --git a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs index 1faec7176f..14151c21a8 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs @@ -116,8 +116,8 @@ public void Process_ExceptionWith_TerminalTrue_StoresInMechanismData() Assert.NotNull(evt.SentryExceptions); var sentryException = evt.SentryExceptions.Single(); - Assert.True(sentryException.Mechanism?.Data.ContainsKey(Mechanism.TerminalKey)); - Assert.Equal(true, sentryException.Mechanism?.Data[Mechanism.TerminalKey]); + Assert.NotNull(sentryException.Mechanism?.Terminal); + Assert.True(sentryException.Mechanism?.Terminal); } [Fact] @@ -133,8 +133,7 @@ public void Process_ExceptionWith_TerminalFalse_StoresInMechanismData() Assert.NotNull(evt.SentryExceptions); var sentryException = evt.SentryExceptions.Single(); - Assert.True(sentryException.Mechanism?.Data.ContainsKey(Mechanism.TerminalKey)); - Assert.Equal(false, sentryException.Mechanism?.Data[Mechanism.TerminalKey]); + Assert.False(sentryException.Mechanism?.Terminal); } [Fact] diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index ed8f7ed75a..0441817eb2 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -231,8 +231,7 @@ public void GetExceptionType_UnhandledNonTerminalException_ReturnsUnhandledNonTe [Fact] public void GetExceptionType_UnhandledNonTerminalExceptionViaSentryExceptions_ReturnsUnhandledNonTerminal() { - var mechanism = new Mechanism { Handled = false }; - mechanism.Data[Mechanism.TerminalKey] = false; + var mechanism = new Mechanism { Handled = false, Terminal = false }; var evt = new SentryEvent { From 4d6fb3fa21aa984335e586cde6a8fd3dccf82843 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 19:58:04 +0200 Subject: [PATCH 20/30] Replaced API --- .../Platforms/Android/LogCatAttachmentEventProcessor.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs index 6c2e1fdf5f..5098c74d70 100644 --- a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs +++ b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs @@ -45,7 +45,9 @@ 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; } @@ -53,7 +55,7 @@ public SentryEvent Process(SentryEvent @event, SentryHint hint) // 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.Unhandled) { return @event; } From 624524f6b9c4d3e35b65772b3a131327e1138ede Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 20:53:57 +0200 Subject: [PATCH 21/30] Added net4_8 verify --- test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 348cb52e24..353bb01587 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -1757,6 +1757,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) { } From b94c4005adce79dda95330b99c0646a968b694ac Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 21:56:46 +0200 Subject: [PATCH 22/30] Updated CHANGELOG.md --- CHANGELOG.md | 2 +- src/Sentry/GlobalSessionManager.cs | 2 +- src/Sentry/SentryClient.cs | 2 +- src/Sentry/SentrySession.cs | 10 ++----- .../Sentry.Tests/GlobalSessionManagerTests.cs | 29 ++----------------- test/Sentry.Tests/SentryClientTests.cs | 4 +-- 6 files changed, 11 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4963e017c1..6f82912502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### 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)) +- 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), [#4653](https://github.com/getsentry/sentry-dotnet/pull/4653)) ### Fixes diff --git a/src/Sentry/GlobalSessionManager.cs b/src/Sentry/GlobalSessionManager.cs index 61ad1f49bb..5f30c64909 100644 --- a/src/Sentry/GlobalSessionManager.cs +++ b/src/Sentry/GlobalSessionManager.cs @@ -250,7 +250,7 @@ private void DeletePersistedSession() private SessionUpdate EndSession(SentrySession session, DateTimeOffset timestamp, SessionEndStatus status) { // If we're ending as 'Exited' but he session has a pending 'Unhandled', end as 'Unhandled' - if (status == SessionEndStatus.Exited && session.HasPendingUnhandledException) + if (status == SessionEndStatus.Exited && session.IsMarkedAsPendingUnhandled) { status = SessionEndStatus.Unhandled; _options.LogDebug("Session ended as 'Unhandled' due to pending status."); diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index b75dc517c1..7e8f9a4111 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -351,7 +351,7 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) switch (exceptionType) { case SentryEvent.ExceptionType.UnhandledNonTerminal: - _options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception."); + _options.LogDebug("Marking session as 'Unhandled', due to non-terminal unhandled exception."); _sessionManager.MarkSessionAsUnhandled(); break; diff --git a/src/Sentry/SentrySession.cs b/src/Sentry/SentrySession.cs index 636de608fe..d7f48a330f 100644 --- a/src/Sentry/SentrySession.cs +++ b/src/Sentry/SentrySession.cs @@ -36,12 +36,12 @@ public class SentrySession : ISentrySession // Start at -1 so that the first increment puts it at 0 private int _sequenceNumber = -1; - private bool _hasPendingUnhandledException; + private bool _isMarkedAsPendingUnhandled; /// /// Gets whether this session has an unhandled exception that hasn't been finalized yet. /// - internal bool HasPendingUnhandledException => _hasPendingUnhandledException; + internal bool IsMarkedAsPendingUnhandled => _isMarkedAsPendingUnhandled; internal SentrySession( SentryId id, @@ -85,11 +85,7 @@ public SentrySession(string? distinctId, string release, string? environment) /// Marks the session as having an unhandled exception without ending it. /// This allows the session to continue and potentially escalate to Crashed if the app crashes. ///
- internal void MarkUnhandledException() - { - _hasPendingUnhandledException = true; - ReportError(); - } + internal void MarkUnhandledException() => _isMarkedAsPendingUnhandled = true; internal SessionUpdate CreateUpdate( bool isInitial, diff --git a/test/Sentry.Tests/GlobalSessionManagerTests.cs b/test/Sentry.Tests/GlobalSessionManagerTests.cs index 5860ad70d1..2649319a4f 100644 --- a/test/Sentry.Tests/GlobalSessionManagerTests.cs +++ b/test/Sentry.Tests/GlobalSessionManagerTests.cs @@ -536,8 +536,7 @@ public void MarkSessionAsUnhandled_ActiveSessionExists_MarksSessionAndPersists() // Assert session.Should().NotBeNull(); - session!.HasPendingUnhandledException.Should().BeTrue(); - session.ErrorCount.Should().Be(1); + session!.IsMarkedAsPendingUnhandled.Should().BeTrue(); // Session should still be active (not ended) sut.CurrentSession.Should().BeSameAs(session); @@ -554,27 +553,10 @@ public void MarkSessionAsUnhandled_NoActiveSession_LogsDebug() // Assert _fixture.Logger.Entries.Should().Contain(e => - e.Message == "No active session to mark as unhandled." && + e.Message == "There is no session active. Skipping marking session as unhandled." && e.Level == SentryLevel.Debug); } - [Fact] - public void MarkSessionAsUnhandled_MultipleUnhandledExceptions_OnlyCountsFirstError() - { - // Arrange - var sut = _fixture.GetSut(); - sut.StartSession(); - var session = sut.CurrentSession; - - // Act - sut.MarkSessionAsUnhandled(); - sut.MarkSessionAsUnhandled(); - sut.MarkSessionAsUnhandled(); - - // Assert - session!.ErrorCount.Should().Be(1); - } - [Fact] public void TryRecoverPersistedSession_WithPendingUnhandledAndNoCrash_EndsAsUnhandled() { @@ -613,10 +595,6 @@ public void TryRecoverPersistedSession_WithPendingUnhandledAndCrash_EscalatesToC // Assert persistedSessionUpdate.Should().NotBeNull(); persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed); - - _fixture.Logger.Entries.Should().Contain(e => - e.Message.Contains("PendingUnhandled: True") && - e.Level == SentryLevel.Info); } [Fact] @@ -655,7 +633,6 @@ public void EndSession_WithPendingUnhandledException_PreservesUnhandledStatus() // Assert - Should be overridden to Unhandled sessionUpdate.Should().NotBeNull(); sessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Unhandled); - sessionUpdate.ErrorCount.Should().Be(1); } [Fact] @@ -689,7 +666,7 @@ public void SessionEscalation_CompleteFlow_UnhandledThenCrash() // Assert: Session still active with pending flag sut.CurrentSession.Should().NotBeNull(); sut.CurrentSession!.Id.Should().Be(originalSessionId); - sut.CurrentSession.HasPendingUnhandledException.Should().BeTrue(); + sut.CurrentSession.IsMarkedAsPendingUnhandled.Should().BeTrue(); // Act 2: Recover on next launch with crash detected _fixture.Options.CrashedLastRun = () => true; diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 2be51cfd98..a1bfd471ac 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1649,7 +1649,7 @@ public void CaptureEvent_ActiveSessionAndUnhandledException_SessionEndedAsCrashe } [Fact] - public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEndedAsUnhandled() + public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionMarkedAsUnhandled() { // Arrange var client = _fixture.GetSut(); @@ -1660,6 +1660,6 @@ public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEn client.CaptureEvent(new SentryEvent(exception)); // Assert - _fixture.SessionManager.Received().EndSession(SessionEndStatus.Unhandled); + _fixture.SessionManager.Received().MarkSessionAsUnhandled(); } } From 225f67a50c7024b6966550f49b6f6348a15b9ed6 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 20 Oct 2025 21:56:46 +0200 Subject: [PATCH 23/30] Cleanup --- CHANGELOG.md | 2 +- src/Sentry/GlobalSessionManager.cs | 2 +- src/Sentry/SentryClient.cs | 2 +- src/Sentry/SentrySession.cs | 10 ++----- .../Sentry.Tests/GlobalSessionManagerTests.cs | 29 ++----------------- test/Sentry.Tests/SentryClientTests.cs | 4 +-- 6 files changed, 11 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4963e017c1..6f82912502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### 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)) +- 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), [#4653](https://github.com/getsentry/sentry-dotnet/pull/4653)) ### Fixes diff --git a/src/Sentry/GlobalSessionManager.cs b/src/Sentry/GlobalSessionManager.cs index 61ad1f49bb..5f30c64909 100644 --- a/src/Sentry/GlobalSessionManager.cs +++ b/src/Sentry/GlobalSessionManager.cs @@ -250,7 +250,7 @@ private void DeletePersistedSession() private SessionUpdate EndSession(SentrySession session, DateTimeOffset timestamp, SessionEndStatus status) { // If we're ending as 'Exited' but he session has a pending 'Unhandled', end as 'Unhandled' - if (status == SessionEndStatus.Exited && session.HasPendingUnhandledException) + if (status == SessionEndStatus.Exited && session.IsMarkedAsPendingUnhandled) { status = SessionEndStatus.Unhandled; _options.LogDebug("Session ended as 'Unhandled' due to pending status."); diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index b75dc517c1..7e8f9a4111 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -351,7 +351,7 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) switch (exceptionType) { case SentryEvent.ExceptionType.UnhandledNonTerminal: - _options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception."); + _options.LogDebug("Marking session as 'Unhandled', due to non-terminal unhandled exception."); _sessionManager.MarkSessionAsUnhandled(); break; diff --git a/src/Sentry/SentrySession.cs b/src/Sentry/SentrySession.cs index 636de608fe..d7f48a330f 100644 --- a/src/Sentry/SentrySession.cs +++ b/src/Sentry/SentrySession.cs @@ -36,12 +36,12 @@ public class SentrySession : ISentrySession // Start at -1 so that the first increment puts it at 0 private int _sequenceNumber = -1; - private bool _hasPendingUnhandledException; + private bool _isMarkedAsPendingUnhandled; /// /// Gets whether this session has an unhandled exception that hasn't been finalized yet. /// - internal bool HasPendingUnhandledException => _hasPendingUnhandledException; + internal bool IsMarkedAsPendingUnhandled => _isMarkedAsPendingUnhandled; internal SentrySession( SentryId id, @@ -85,11 +85,7 @@ public SentrySession(string? distinctId, string release, string? environment) /// Marks the session as having an unhandled exception without ending it. /// This allows the session to continue and potentially escalate to Crashed if the app crashes. /// - internal void MarkUnhandledException() - { - _hasPendingUnhandledException = true; - ReportError(); - } + internal void MarkUnhandledException() => _isMarkedAsPendingUnhandled = true; internal SessionUpdate CreateUpdate( bool isInitial, diff --git a/test/Sentry.Tests/GlobalSessionManagerTests.cs b/test/Sentry.Tests/GlobalSessionManagerTests.cs index 5860ad70d1..2649319a4f 100644 --- a/test/Sentry.Tests/GlobalSessionManagerTests.cs +++ b/test/Sentry.Tests/GlobalSessionManagerTests.cs @@ -536,8 +536,7 @@ public void MarkSessionAsUnhandled_ActiveSessionExists_MarksSessionAndPersists() // Assert session.Should().NotBeNull(); - session!.HasPendingUnhandledException.Should().BeTrue(); - session.ErrorCount.Should().Be(1); + session!.IsMarkedAsPendingUnhandled.Should().BeTrue(); // Session should still be active (not ended) sut.CurrentSession.Should().BeSameAs(session); @@ -554,27 +553,10 @@ public void MarkSessionAsUnhandled_NoActiveSession_LogsDebug() // Assert _fixture.Logger.Entries.Should().Contain(e => - e.Message == "No active session to mark as unhandled." && + e.Message == "There is no session active. Skipping marking session as unhandled." && e.Level == SentryLevel.Debug); } - [Fact] - public void MarkSessionAsUnhandled_MultipleUnhandledExceptions_OnlyCountsFirstError() - { - // Arrange - var sut = _fixture.GetSut(); - sut.StartSession(); - var session = sut.CurrentSession; - - // Act - sut.MarkSessionAsUnhandled(); - sut.MarkSessionAsUnhandled(); - sut.MarkSessionAsUnhandled(); - - // Assert - session!.ErrorCount.Should().Be(1); - } - [Fact] public void TryRecoverPersistedSession_WithPendingUnhandledAndNoCrash_EndsAsUnhandled() { @@ -613,10 +595,6 @@ public void TryRecoverPersistedSession_WithPendingUnhandledAndCrash_EscalatesToC // Assert persistedSessionUpdate.Should().NotBeNull(); persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed); - - _fixture.Logger.Entries.Should().Contain(e => - e.Message.Contains("PendingUnhandled: True") && - e.Level == SentryLevel.Info); } [Fact] @@ -655,7 +633,6 @@ public void EndSession_WithPendingUnhandledException_PreservesUnhandledStatus() // Assert - Should be overridden to Unhandled sessionUpdate.Should().NotBeNull(); sessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Unhandled); - sessionUpdate.ErrorCount.Should().Be(1); } [Fact] @@ -689,7 +666,7 @@ public void SessionEscalation_CompleteFlow_UnhandledThenCrash() // Assert: Session still active with pending flag sut.CurrentSession.Should().NotBeNull(); sut.CurrentSession!.Id.Should().Be(originalSessionId); - sut.CurrentSession.HasPendingUnhandledException.Should().BeTrue(); + sut.CurrentSession.IsMarkedAsPendingUnhandled.Should().BeTrue(); // Act 2: Recover on next launch with crash detected _fixture.Options.CrashedLastRun = () => true; diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 2be51cfd98..a1bfd471ac 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1649,7 +1649,7 @@ public void CaptureEvent_ActiveSessionAndUnhandledException_SessionEndedAsCrashe } [Fact] - public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEndedAsUnhandled() + public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionMarkedAsUnhandled() { // Arrange var client = _fixture.GetSut(); @@ -1660,6 +1660,6 @@ public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEn client.CaptureEvent(new SentryEvent(exception)); // Assert - _fixture.SessionManager.Received().EndSession(SessionEndStatus.Unhandled); + _fixture.SessionManager.Received().MarkSessionAsUnhandled(); } } From f06513dd9d54de19e3f9b7915fe8f85fd609c269 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 21 Oct 2025 12:40:13 +0200 Subject: [PATCH 24/30] Is this the one that is missing? --- ...fault_ones_are_properly_registered.Net4_8.Mono.verified.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt b/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt index be7336d126..e9aa248faa 100644 --- a/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt +++ b/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt @@ -5,6 +5,9 @@ https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647 ] }, + { + Message: Starting BackpressureMonitor. + }, { Message: Registering integration: '{0}'., Args: [ From 01d6bb83808f8125b1d290cca4544eb148e80bc1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 21 Oct 2025 13:29:52 +0200 Subject: [PATCH 25/30] Fixed my own mess. yey. --- test/Sentry.Tests/Sentry.Tests.csproj | 2 +- ...fault_ones_are_properly_registered.Net4_8.Mono.verified.txt | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/test/Sentry.Tests/Sentry.Tests.csproj b/test/Sentry.Tests/Sentry.Tests.csproj index 9802ef9f96..1a6e4f3ef8 100644 --- a/test/Sentry.Tests/Sentry.Tests.csproj +++ b/test/Sentry.Tests/Sentry.Tests.csproj @@ -1,7 +1,7 @@  - $(CurrentTfms);net48 + $(CurrentTfms) $(TargetFrameworks);$(LatestAndroidTfm);$(PreviousAndroidTfm) $(TargetFrameworks);$(LatestIosTfm);$(PreviousIosTfm) $(TargetFrameworks);$(LatestMacCatalystTfm);$(PreviousMacCatalystTfm) diff --git a/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt b/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt index e9aa248faa..be7336d126 100644 --- a/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt +++ b/test/Sentry.Tests/SentryOptionsTests.Integrations_default_ones_are_properly_registered.Net4_8.Mono.verified.txt @@ -5,9 +5,6 @@ https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647 ] }, - { - Message: Starting BackpressureMonitor. - }, { Message: Registering integration: '{0}'., Args: [ From a7ff3277d14b28698fd361f42b79e809c9158d59 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 21 Oct 2025 16:53:06 +0200 Subject: [PATCH 26/30] Interlock marking --- src/Sentry/SentrySession.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry/SentrySession.cs b/src/Sentry/SentrySession.cs index d7f48a330f..e8141de21a 100644 --- a/src/Sentry/SentrySession.cs +++ b/src/Sentry/SentrySession.cs @@ -36,12 +36,12 @@ public class SentrySession : ISentrySession // Start at -1 so that the first increment puts it at 0 private int _sequenceNumber = -1; - private bool _isMarkedAsPendingUnhandled; + private int _isMarkedAsPendingUnhandled; /// /// Gets whether this session has an unhandled exception that hasn't been finalized yet. /// - internal bool IsMarkedAsPendingUnhandled => _isMarkedAsPendingUnhandled; + internal bool IsMarkedAsPendingUnhandled => _isMarkedAsPendingUnhandled != 0; internal SentrySession( SentryId id, @@ -85,7 +85,7 @@ public SentrySession(string? distinctId, string release, string? environment) /// Marks the session as having an unhandled exception without ending it. /// This allows the session to continue and potentially escalate to Crashed if the app crashes. /// - internal void MarkUnhandledException() => _isMarkedAsPendingUnhandled = true; + internal void MarkUnhandledException() => Interlocked.Exchange(ref _isMarkedAsPendingUnhandled, 1); internal SessionUpdate CreateUpdate( bool isInitial, From a41b316d6a4b6bd3c4bcc88e0eae7cb2f3a9a0e7 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Fri, 24 Oct 2025 11:03:41 +0200 Subject: [PATCH 27/30] Update src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs Co-authored-by: James Crosswell --- src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs index 5098c74d70..380ea86570 100644 --- a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs +++ b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs @@ -55,7 +55,7 @@ public SentryEvent Process(SentryEvent @event, SentryHint hint) // Only send logcat logs if the event is unhandled if the integration is set to Unhandled if (_logCatIntegrationType == LogCatIntegrationType.Unhandled) { - if (exceptionType != SentryEvent.ExceptionType.Unhandled) + if (exceptionType != SentryEvent.ExceptionType.Unhandled && exceptionType != SentryEvent.ExceptionType.UnhandledNonTerminal) { return @event; } From a1500c3b7cbd1a4e82fa66095cdea656ec762167 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 24 Oct 2025 12:17:40 +0200 Subject: [PATCH 28/30] Unhandled -> UnhandledTerminal --- src/Sentry/Internal/Hub.cs | 2 +- .../Platforms/Android/LogCatAttachmentEventProcessor.cs | 2 +- src/Sentry/SentryClient.cs | 2 +- src/Sentry/SentryEvent.cs | 4 ++-- test/Sentry.Tests/Protocol/SentryEventTests.cs | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 5ce89a3cb6..c27e0eb95e 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -585,7 +585,7 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope) scope.LastEventId = id; scope.SessionUpdate = null; - if (evt.GetExceptionType() is SentryEvent.ExceptionType.Unhandled + if (evt.GetExceptionType() is SentryEvent.ExceptionType.UnhandledTerminal && scope.Transaction is { } transaction) { // Event contains a terminal exception -> finish any current transaction as aborted diff --git a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs index 380ea86570..89879f813d 100644 --- a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs +++ b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs @@ -55,7 +55,7 @@ public SentryEvent Process(SentryEvent @event, SentryHint hint) // Only send logcat logs if the event is unhandled if the integration is set to Unhandled if (_logCatIntegrationType == LogCatIntegrationType.Unhandled) { - if (exceptionType != SentryEvent.ExceptionType.Unhandled && exceptionType != SentryEvent.ExceptionType.UnhandledNonTerminal) + if (exceptionType != SentryEvent.ExceptionType.UnhandledTerminal && exceptionType != SentryEvent.ExceptionType.UnhandledNonTerminal) { return @event; } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index d8cf1bb842..99efe7d9fa 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -355,7 +355,7 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled); break; - case SentryEvent.ExceptionType.Unhandled: + case SentryEvent.ExceptionType.UnhandledTerminal: _options.LogDebug("Ending session as 'Crashed', due to unhandled exception."); scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed); break; diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 662b60d1ad..c26937cbfc 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -182,7 +182,7 @@ internal enum ExceptionType { None, Handled, - Unhandled, + UnhandledTerminal, UnhandledNonTerminal } @@ -200,7 +200,7 @@ internal ExceptionType GetExceptionType() if (HasUnhandledException()) { - return ExceptionType.Unhandled; + return ExceptionType.UnhandledTerminal; } return ExceptionType.Handled; diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index 0441817eb2..f7477f5136 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -204,7 +204,7 @@ public void GetExceptionType_UnhandledTerminalException_ReturnsUnhandled() exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); var evt = new SentryEvent(exception); - Assert.Equal(SentryEvent.ExceptionType.Unhandled, evt.GetExceptionType()); + Assert.Equal(SentryEvent.ExceptionType.UnhandledTerminal, evt.GetExceptionType()); } [Fact] @@ -215,7 +215,7 @@ public void GetExceptionType_UnhandledTerminalExceptionViaSentryExceptions_Retur SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] }; - Assert.Equal(SentryEvent.ExceptionType.Unhandled, evt.GetExceptionType()); + Assert.Equal(SentryEvent.ExceptionType.UnhandledTerminal, evt.GetExceptionType()); } [Fact] From 73a6c012693dbf7681539a28bd7b6d4b519e05f4 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Wed, 29 Oct 2025 13:15:08 +0100 Subject: [PATCH 29/30] Apply suggestions from code review Co-authored-by: James Crosswell --- src/Sentry/GlobalSessionManager.cs | 2 +- src/Sentry/SentrySession.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sentry/GlobalSessionManager.cs b/src/Sentry/GlobalSessionManager.cs index 5f30c64909..f0b62627f6 100644 --- a/src/Sentry/GlobalSessionManager.cs +++ b/src/Sentry/GlobalSessionManager.cs @@ -253,7 +253,7 @@ private SessionUpdate EndSession(SentrySession session, DateTimeOffset timestamp if (status == SessionEndStatus.Exited && session.IsMarkedAsPendingUnhandled) { status = SessionEndStatus.Unhandled; - _options.LogDebug("Session ended as 'Unhandled' due to pending status."); + _options.LogDebug("Session ended with pending 'Unhandled' (but not `Terminal`) exception."); } if (status == SessionEndStatus.Crashed) diff --git a/src/Sentry/SentrySession.cs b/src/Sentry/SentrySession.cs index e8141de21a..a251220934 100644 --- a/src/Sentry/SentrySession.cs +++ b/src/Sentry/SentrySession.cs @@ -36,12 +36,12 @@ public class SentrySession : ISentrySession // Start at -1 so that the first increment puts it at 0 private int _sequenceNumber = -1; - private int _isMarkedAsPendingUnhandled; + private InterlockedBoolean _isMarkedAsPendingUnhandled; /// /// Gets whether this session has an unhandled exception that hasn't been finalized yet. /// - internal bool IsMarkedAsPendingUnhandled => _isMarkedAsPendingUnhandled != 0; + internal bool IsMarkedAsPendingUnhandled => _isMarkedAsPendingUnhandled; internal SentrySession( SentryId id, @@ -85,7 +85,7 @@ public SentrySession(string? distinctId, string release, string? environment) /// Marks the session as having an unhandled exception without ending it. /// This allows the session to continue and potentially escalate to Crashed if the app crashes. /// - internal void MarkUnhandledException() => Interlocked.Exchange(ref _isMarkedAsPendingUnhandled, 1); + internal void MarkUnhandledException() => _isMarkedAsPendingUnhandled.Exchange(true); internal SessionUpdate CreateUpdate( bool isInitial, From 1a72f298f4ac78312ac0dabba13ac3943d218abe Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 29 Oct 2025 13:55:07 +0100 Subject: [PATCH 30/30] Fix import and persist unhandled mark when pausing --- src/Sentry/GlobalSessionManager.cs | 2 +- src/Sentry/SentrySession.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Sentry/GlobalSessionManager.cs b/src/Sentry/GlobalSessionManager.cs index f0b62627f6..8fb6cb8413 100644 --- a/src/Sentry/GlobalSessionManager.cs +++ b/src/Sentry/GlobalSessionManager.cs @@ -299,7 +299,7 @@ public void PauseSession() var now = _clock.GetUtcNow(); _lastPauseTimestamp = now; - PersistSession(session.CreateUpdate(false, now), now); + PersistSession(session.CreateUpdate(false, now), now, session.IsMarkedAsPendingUnhandled); } public IReadOnlyList ResumeSession() diff --git a/src/Sentry/SentrySession.cs b/src/Sentry/SentrySession.cs index a251220934..b92f6f94b0 100644 --- a/src/Sentry/SentrySession.cs +++ b/src/Sentry/SentrySession.cs @@ -1,3 +1,5 @@ +using Sentry.Internal; + namespace Sentry; ///