diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d103949ba..03006075e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ See https://develop.sentry.dev/sdk/telemetry/traces/distributed-tracing/#w3c-trace-context-header for more details. +### Features + +- The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633)) + ### Fixes - The SDK avoids redundant scope sync after transaction finish ([#4623](https://github.com/getsentry/sentry-dotnet/pull/4623)) diff --git a/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs b/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs index a403186904..3e3b658bc2 100644 --- a/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs +++ b/src/Sentry/Integrations/UnobservedTaskExceptionIntegration.cs @@ -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 d4990363b9..c27e0eb95e 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -585,7 +585,8 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope) scope.LastEventId = id; scope.SessionUpdate = null; - if (evt.HasTerminalException() && scope.Transaction is { } transaction) + if (evt.GetExceptionType() is SentryEvent.ExceptionType.UnhandledTerminal + && scope.Transaction is { } transaction) { // Event contains a terminal exception -> finish any current transaction as aborted // Do this *after* the event was captured, so that the event is still linked to the transaction. diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index b80ab1ea94..3a5d524df4 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -199,6 +199,12 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId exception.Data.Remove(Mechanism.DescriptionKey); } + if (exception.Data[Mechanism.TerminalKey] is bool terminal) + { + mechanism.Terminal = terminal; + exception.Data.Remove(Mechanism.TerminalKey); + } + // Add HResult to mechanism data before adding exception data, so that it can be overridden. mechanism.Data["HResult"] = $"0x{exception.HResult:X8}"; diff --git a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs index 6c2e1fdf5f..89879f813d 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.UnhandledTerminal && exceptionType != SentryEvent.ExceptionType.UnhandledNonTerminal) { return @event; } diff --git a/src/Sentry/Protocol/Mechanism.cs b/src/Sentry/Protocol/Mechanism.cs index ff0eb014fd..3b37ab5b64 100644 --- a/src/Sentry/Protocol/Mechanism.cs +++ b/src/Sentry/Protocol/Mechanism.cs @@ -29,6 +29,14 @@ 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. + /// + /// + /// 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; } internal Dictionary? InternalMeta { get; private set; } @@ -76,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. /// @@ -133,6 +150,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteStringIfNotWhiteSpace("source", Source); writer.WriteStringIfNotWhiteSpace("help_link", HelpLink); writer.WriteBooleanIfNotNull("handled", Handled); + // Note: Terminal is NOT serialized - it's SDK-internal only writer.WriteBooleanIfTrue("synthetic", Synthetic); writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup); writer.WriteNumberIfNotNull("exception_id", ExceptionId); diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index d42fe8c44f..99efe7d9fa 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -347,18 +347,23 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return SentryId.Empty; // Dropped by BeforeSend callback } - var hasTerminalException = processedEvent.HasTerminalException(); - if (hasTerminalException) - { - // Event contains a terminal exception -> end session as crashed - _options.LogDebug("Ending session as Crashed, due to unhandled exception."); - scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed); - } - else if (processedEvent.HasException()) - { - // Event contains a non-terminal exception -> report error - // (this might return null if the session has already reported errors before) - scope.SessionUpdate = _sessionManager.ReportError(); + var exceptionType = processedEvent.GetExceptionType(); + switch (exceptionType) + { + case SentryEvent.ExceptionType.UnhandledNonTerminal: + _options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception."); + scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled); + break; + + case SentryEvent.ExceptionType.UnhandledTerminal: + _options.LogDebug("Ending session as 'Crashed', due to unhandled exception."); + scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed); + break; + + case SentryEvent.ExceptionType.Handled: + _options.LogDebug("Updating session by reporting an error."); + scope.SessionUpdate = _sessionManager.ReportError(); + break; } if (_options.SampleRate != null) diff --git a/src/Sentry/SentryEvent.cs b/src/Sentry/SentryEvent.cs index 81abbe4ddd..c26937cbfc 100644 --- a/src/Sentry/SentryEvent.cs +++ b/src/Sentry/SentryEvent.cs @@ -178,21 +178,64 @@ public IReadOnlyList Fingerprint /// public IReadOnlyDictionary Tags => _tags ??= new Dictionary(); - internal bool HasException() => Exception is not null || SentryExceptions?.Any() == true; + internal enum ExceptionType + { + None, + Handled, + UnhandledTerminal, + UnhandledNonTerminal + } - internal bool HasTerminalException() + internal ExceptionType GetExceptionType() { - // The exception is considered terminal if it is marked unhandled, - // UNLESS it comes from the UnobservedTaskExceptionIntegration + if (!HasException()) + { + return ExceptionType.None; + } + + if (HasUnhandledNonTerminalException()) + { + return ExceptionType.UnhandledNonTerminal; + } + + if (HasUnhandledException()) + { + return ExceptionType.UnhandledTerminal; + } + + return ExceptionType.Handled; + } + private bool HasException() => Exception is not null || SentryExceptions?.Any() == true; + + private bool HasUnhandledException() + { if (Exception?.Data[Mechanism.HandledKey] is false) { - return Exception.Data[Mechanism.MechanismKey] as string != UnobservedTaskExceptionIntegration.MechanismKey; + return true; + } + + return SentryExceptions?.Any(e => e.Mechanism is { Handled: false }) ?? false; + } + + private bool HasUnhandledNonTerminalException() + { + // Generally, an unhandled exception is considered terminal. + // Exception: If it is an unhandled exception but the terminal flag is explicitly set to false. + // I.e. captured through the UnobservedTaskExceptionIntegration, or the exception capture integrations in the Unity SDK + + if (Exception?.Data[Mechanism.HandledKey] is false) + { + if (Exception.Data[Mechanism.TerminalKey] is false) + { + return true; + } + + return false; } return SentryExceptions?.Any(e => - e.Mechanism is { Handled: false } mechanism && - mechanism.Type != UnobservedTaskExceptionIntegration.MechanismKey + e.Mechanism is { Handled: false, Terminal: false } ) ?? false; } diff --git a/src/Sentry/SentryExceptionExtensions.cs b/src/Sentry/SentryExceptionExtensions.cs index 4ecac3239b..822ad8c856 100644 --- a/src/Sentry/SentryExceptionExtensions.cs +++ b/src/Sentry/SentryExceptionExtensions.cs @@ -32,8 +32,9 @@ 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 = null) { ex.Data[Mechanism.MechanismKey] = type; @@ -54,5 +55,14 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de { ex.Data[Mechanism.HandledKey] = handled; } + + if (terminal == null) + { + ex.Data.Remove(Mechanism.TerminalKey); + } + else + { + ex.Data[Mechanism.TerminalKey] = terminal; + } } } 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/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index c2c782305e..e8d1b7df21 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1065,8 +1065,9 @@ namespace Sentry public enum SessionEndStatus { Exited = 0, - Crashed = 1, - Abnormal = 2, + Unhandled = 1, + Crashed = 2, + Abnormal = 3, } public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession { @@ -1786,6 +1787,7 @@ namespace Sentry.Protocol public static readonly string DescriptionKey; public static readonly string HandledKey; public static readonly string MechanismKey; + public static readonly string TerminalKey; public Mechanism() { } public System.Collections.Generic.IDictionary Data { get; } public string? Description { get; set; } @@ -1797,6 +1799,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; } public string Type { get; set; } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { } @@ -1925,5 +1928,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 diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index c2c782305e..e8d1b7df21 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1065,8 +1065,9 @@ namespace Sentry public enum SessionEndStatus { Exited = 0, - Crashed = 1, - Abnormal = 2, + Unhandled = 1, + Crashed = 2, + Abnormal = 3, } public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession { @@ -1786,6 +1787,7 @@ namespace Sentry.Protocol public static readonly string DescriptionKey; public static readonly string HandledKey; public static readonly string MechanismKey; + public static readonly string TerminalKey; public Mechanism() { } public System.Collections.Generic.IDictionary Data { get; } public string? Description { get; set; } @@ -1797,6 +1799,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; } public string Type { get; set; } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { } @@ -1925,5 +1928,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 diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index c2c782305e..e8d1b7df21 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1065,8 +1065,9 @@ namespace Sentry public enum SessionEndStatus { Exited = 0, - Crashed = 1, - Abnormal = 2, + Unhandled = 1, + Crashed = 2, + Abnormal = 3, } public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession { @@ -1786,6 +1787,7 @@ namespace Sentry.Protocol public static readonly string DescriptionKey; public static readonly string HandledKey; public static readonly string MechanismKey; + public static readonly string TerminalKey; public Mechanism() { } public System.Collections.Generic.IDictionary Data { get; } public string? Description { get; set; } @@ -1797,6 +1799,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; } public string Type { get; set; } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { } @@ -1925,5 +1928,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 diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 9cd54f7fa0..538d76be9f 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -1041,8 +1041,9 @@ namespace Sentry public enum SessionEndStatus { Exited = 0, - Crashed = 1, - Abnormal = 2, + Unhandled = 1, + Crashed = 2, + Abnormal = 3, } public class SessionUpdate : Sentry.ISentryJsonSerializable, Sentry.ISentrySession { @@ -1757,6 +1758,7 @@ namespace Sentry.Protocol public static readonly string DescriptionKey; public static readonly string HandledKey; public static readonly string MechanismKey; + public static readonly string TerminalKey; public Mechanism() { } public System.Collections.Generic.IDictionary Data { get; } public string? Description { get; set; } @@ -1768,6 +1770,7 @@ namespace Sentry.Protocol public int? ParentId { get; set; } public string? Source { get; set; } public bool Synthetic { get; set; } + public bool? Terminal { get; } public string Type { get; set; } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.Protocol.Mechanism FromJson(System.Text.Json.JsonElement json) { } @@ -1896,5 +1899,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 diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index c04fc23a94..195d99fbb4 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, terminal: true); + + // 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/Internals/MainExceptionProcessorTests.cs b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs index f5dedc0b4e..14151c21a8 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs @@ -103,6 +103,39 @@ 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.SetSentryMechanism("TestException", terminal: true); + + sut.Process(exp, evt); + + Assert.NotNull(evt.SentryExceptions); + var sentryException = evt.SentryExceptions.Single(); + Assert.NotNull(sentryException.Mechanism?.Terminal); + Assert.True(sentryException.Mechanism?.Terminal); + } + + [Fact] + public void Process_ExceptionWith_TerminalFalse_StoresInMechanismData() + { + var sut = _fixture.GetSut(); + var evt = new SentryEvent(); + var exp = new Exception(); + + exp.SetSentryMechanism("TestException", terminal: false); + + sut.Process(exp, evt); + + Assert.NotNull(evt.SentryExceptions); + var sentryException = evt.SentryExceptions.Single(); + Assert.False(sentryException.Mechanism?.Terminal); + } + [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 e5ae10a885..1f310a859d 100644 --- a/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs +++ b/test/Sentry.Tests/Protocol/Exceptions/MechanismTests.cs @@ -74,4 +74,35 @@ public static IEnumerable TestCases() 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"}}""") }; } + + [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 37b397c548..f7477f5136 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -167,4 +167,77 @@ public void Redact_Redacts_Urls() evt.Tags["tag_key"].Should().Be(tagValue); } } + + [Fact] + public void GetExceptionType_NoException_ReturnsNone() + { + var evt = new SentryEvent(); + + Assert.Equal(SentryEvent.ExceptionType.None, evt.GetExceptionType()); + } + + [Fact] + public void GetExceptionType_HandledException_ReturnsHandled() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("test", handled: true); + var evt = new SentryEvent(exception); + + Assert.Equal(SentryEvent.ExceptionType.Handled, evt.GetExceptionType()); + } + + [Fact] + public void GetExceptionType_HandledExceptionViaSentryExceptions_ReturnsHandled() + { + var evt = new SentryEvent + { + SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = true } }] + }; + + Assert.Equal(SentryEvent.ExceptionType.Handled, evt.GetExceptionType()); + } + + [Fact] + public void GetExceptionType_UnhandledTerminalException_ReturnsUnhandled() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("AppDomain.UnhandledException", handled: false, terminal: true); + var evt = new SentryEvent(exception); + + Assert.Equal(SentryEvent.ExceptionType.UnhandledTerminal, evt.GetExceptionType()); + } + + [Fact] + public void GetExceptionType_UnhandledTerminalExceptionViaSentryExceptions_ReturnsUnhandled() + { + var evt = new SentryEvent + { + SentryExceptions = [new SentryException { Mechanism = new Mechanism { Handled = false } }] + }; + + Assert.Equal(SentryEvent.ExceptionType.UnhandledTerminal, evt.GetExceptionType()); + } + + [Fact] + public void GetExceptionType_UnhandledNonTerminalException_ReturnsUnhandledNonTerminal() + { + var exception = new Exception("test"); + exception.SetSentryMechanism("UnobservedTaskException", handled: false, terminal: false); + var evt = new SentryEvent(exception); + + Assert.Equal(SentryEvent.ExceptionType.UnhandledNonTerminal, evt.GetExceptionType()); + } + + [Fact] + public void GetExceptionType_UnhandledNonTerminalExceptionViaSentryExceptions_ReturnsUnhandledNonTerminal() + { + var mechanism = new Mechanism { Handled = false, Terminal = false }; + + var evt = new SentryEvent + { + SentryExceptions = [new SentryException { Mechanism = mechanism }] + }; + + Assert.Equal(SentryEvent.ExceptionType.UnhandledNonTerminal, evt.GetExceptionType()); + } } diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 2a2987b9ef..2be51cfd98 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1635,27 +1635,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); + } }