diff --git a/src/Sentry.Extensions.Logging/SentryLogger.cs b/src/Sentry.Extensions.Logging/SentryLogger.cs index 65eb1ff4da..05013d0fef 100644 --- a/src/Sentry.Extensions.Logging/SentryLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryLogger.cs @@ -82,7 +82,8 @@ public void Log( CategoryName, null, data, - logLevel.ToBreadcrumbLevel()); + logLevel.ToBreadcrumbLevel(), + exception.ToHint()); } } diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index a4bc0367f1..2d9faaaf55 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -172,7 +172,14 @@ private void AddBreadcrumbFromLoggingEvent(LoggingEvent loggingEvent) .Where(kvp => kvp.Value != null) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.ToString() ?? ""); - _hub.AddBreadcrumb(message, category, type: null, data, level ?? default); + _hub.AddBreadcrumb( + clock: null, + message, + category, + type: null, + data, + level ?? default, + hint: loggingEvent.ExceptionObject.ToHint()); return; } diff --git a/src/Sentry.NLog/SentryTarget.cs b/src/Sentry.NLog/SentryTarget.cs index e1777e49ff..89eef117d3 100644 --- a/src/Sentry.NLog/SentryTarget.cs +++ b/src/Sentry.NLog/SentryTarget.cs @@ -425,8 +425,10 @@ private void CreateBreadcrumb(LogEventInfo logEvent, Exception? exception, bool _clock, message, breadcrumbCategory, + type: null, data: data, - level: logEvent.Level.ToBreadcrumbLevel()); + level: logEvent.Level.ToBreadcrumbLevel(), + hint: exception.ToHint()); } private void CreateSentryEvent(LogEventInfo logEvent, Exception? exception, bool shouldIncludeProperties, IHub hub) diff --git a/src/Sentry.Serilog/SentrySink.cs b/src/Sentry.Serilog/SentrySink.cs index 8076d2da6d..9ace4793d3 100644 --- a/src/Sentry.Serilog/SentrySink.cs +++ b/src/Sentry.Serilog/SentrySink.cs @@ -161,8 +161,10 @@ private void InnerEmit(LogEvent logEvent) ? exception?.Message ?? "" : formatted, context, + type: null, data: data, - level: logEvent.Level.ToBreadcrumbLevel()); + level: logEvent.Level.ToBreadcrumbLevel(), + hint: exception.ToHint()); } // Read the options from the Hub, rather than the Sink's Serilog-Options, because 'EnableLogs' is declared in the base 'SentryOptions', rather than the derived 'SentrySerilogOptions'. diff --git a/src/Sentry/HintTypes.cs b/src/Sentry/HintTypes.cs index 12439822f2..1d5d5b36aa 100644 --- a/src/Sentry/HintTypes.cs +++ b/src/Sentry/HintTypes.cs @@ -9,4 +9,9 @@ public static class HintTypes /// Used for HttpResponseMessage hints /// public const string HttpResponseMessage = "http-response-message"; + + /// + /// Used for the that a breadcrumb was created from + /// + public const string Exception = "exception"; } diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 8874741448..26958a7e71 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -191,6 +191,32 @@ public static void AddBreadcrumb( string? type = null, IDictionary? data = null, BreadcrumbLevel level = default) + => hub.AddBreadcrumb(clock, message, category, type, data, level, hint: null); + + /// + /// Adds a breadcrumb using a custom which allows better testability. + /// + /// The Hub which holds the scope stack. + /// The system clock. + /// The message. + /// Category. + /// Breadcrumb type. + /// Additional data. + /// Breadcrumb level. + /// A hint provided with the breadcrumb in the BeforeBreadcrumb callback. + /// + /// This method is to be used by integrations to allow testing. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void AddBreadcrumb( + this IHub hub, + ISystemClock? clock, + string message, + string? category, + string? type, + IDictionary? data, + BreadcrumbLevel level, + SentryHint? hint) { // Not to throw on code that ignores nullability warnings. if (hub.IsNull()) @@ -207,9 +233,7 @@ public static void AddBreadcrumb( level ); - hub.AddBreadcrumb( - breadcrumb - ); + hub.AddBreadcrumb(breadcrumb, hint); } /// diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index ab97ab60ae..bfcfa3e834 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -587,7 +587,17 @@ private void AddBreadcrumbForException(SentryEvent evt, Scope scope) {"exception_message", exceptionMessage} }; } - scope.AddBreadcrumb(breadcrumbMessage, "Exception", data: data, level: BreadcrumbLevel.Fatal); + + var hint = new SentryHint(_options); + hint.Items[HintTypes.Exception] = exception; + + var breadcrumb = new Breadcrumb( + message: breadcrumbMessage, + data: data, + category: "Exception", + level: BreadcrumbLevel.Fatal); + + scope.AddBreadcrumb(breadcrumb, hint); } catch (Exception e) { diff --git a/src/Sentry/SentryExceptionExtensions.cs b/src/Sentry/SentryExceptionExtensions.cs index da78c8fea3..35d59de122 100644 --- a/src/Sentry/SentryExceptionExtensions.cs +++ b/src/Sentry/SentryExceptionExtensions.cs @@ -1,3 +1,4 @@ +using Sentry; using Sentry.Internal; using Sentry.Protocol; @@ -7,6 +8,14 @@ [EditorBrowsable(EditorBrowsableState.Never)] public static class SentryExceptionExtensions { + /// + /// Creates a carrying the exception that a breadcrumb was created from, or + /// if there is no exception. + /// + internal static SentryHint? ToHint(this Exception? exception) => exception is null + ? null + : new SentryHint(HintTypes.Exception, exception); + /// /// Set a tag that will be added to the event when the exception is captured. /// diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs index 02f2bf9531..8a748b030a 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs @@ -58,6 +58,26 @@ public void Log_EventWithoutException_LeavesBreadcrumb() _fixture.Scope.Breadcrumbs.Should().NotBeEmpty(); } + [Fact] + public void Log_BreadcrumbWithException_ProvidesExceptionInHint() + { + SentryHint hint = null; + _fixture.Scope.Options.SetBeforeBreadcrumb((breadcrumb, h) => + { + hint = h; + return breadcrumb; + }); + var expectedException = new Exception("expected message"); + + var sut = _fixture.GetSut(); + + // LogLevel.Warning is below the default MinimumEventLevel, so only a breadcrumb is added + sut.Log(LogLevel.Warning, default, null, expectedException, null); + + hint.Should().NotBeNull(); + hint.Items[HintTypes.Exception].Should().BeSameAs(expectedException); + } + [Fact] public void Log_WithEventId_EventIdAsTagOnEvent() { diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs index cfcad5c7c9..afd4b13000 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs @@ -345,6 +345,28 @@ public void DoAppend_BelowMinimumEventLevel_AddsBreadcrumb() Assert.Equal(expectedBreadcrumbMsg, breadcrumb.Message); } + [Fact] + public void DoAppend_BreadcrumbWithException_ProvidesExceptionInHint() + { + SentryHint hint = null; + _fixture.Scope.Options.SetBeforeBreadcrumb((breadcrumb, h) => + { + hint = h; + return breadcrumb; + }); + var expectedException = new Exception("expected"); + + var sut = _fixture.GetSut(); + sut.Threshold = Level.Debug; + sut.MinimumEventLevel = Level.Error; + + // Level.Warn is below the MinimumEventLevel, so only a breadcrumb is added + sut.DoAppend(new LoggingEvent(null, null, "logger", Level.Warn, "log4net breadcrumb", expectedException)); + + hint.Should().NotBeNull(); + hint.Items[HintTypes.Exception].Should().BeSameAs(expectedException); + } + [Fact] public void DoAppend_NullMinimumEventLevel_AddsEvent() { diff --git a/test/Sentry.NLog.Tests/SentryTargetTests.cs b/test/Sentry.NLog.Tests/SentryTargetTests.cs index 2bb97d6e7a..8b5c9fef76 100644 --- a/test/Sentry.NLog.Tests/SentryTargetTests.cs +++ b/test/Sentry.NLog.Tests/SentryTargetTests.cs @@ -191,6 +191,27 @@ public void Log_WithoutException_LeavesBreadcrumb() _fixture.Scope.Breadcrumbs.Should().NotBeEmpty(); } + [Fact] + public void Log_BreadcrumbWithException_ProvidesExceptionInHint() + { + SentryHint hint = null; + _fixture.Scope.Options.SetBeforeBreadcrumb((breadcrumb, h) => + { + hint = h; + return breadcrumb; + }); + var expectedException = new Exception("expected"); + + _fixture.Options.MinimumEventLevel = LogLevel.Fatal; + var logger = _fixture.GetLogger(); + + // LogLevel.Error is below the MinimumEventLevel, so only a breadcrumb is added + logger.Error(expectedException, DefaultMessage); + + hint.Should().NotBeNull(); + hint.Items[HintTypes.Exception].Should().BeSameAs(expectedException); + } + [Fact] public void Log_WithException_CreatesEventWithException() { diff --git a/test/Sentry.Serilog.Tests/SentrySinkTests.cs b/test/Sentry.Serilog.Tests/SentrySinkTests.cs index 18ce0a4561..e90c405ec3 100644 --- a/test/Sentry.Serilog.Tests/SentrySinkTests.cs +++ b/test/Sentry.Serilog.Tests/SentrySinkTests.cs @@ -76,6 +76,29 @@ public void EmitEvent_WithoutException_LeavesBreadcrumb() _fixture.Scope.Breadcrumbs.Should().NotBeEmpty(); } + [Fact] + public void EmitBreadcrumb_WithException_ProvidesExceptionInHint() + { + SentryHint hint = null; + _fixture.Scope.Options.SetBeforeBreadcrumb((breadcrumb, h) => + { + hint = h; + return breadcrumb; + }); + var expectedException = new Exception("expected message"); + + var sut = _fixture.GetSut(); + + // LogEventLevel.Warning is below the default MinimumEventLevel, so only a breadcrumb is added + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Warning, expectedException, + MessageTemplate.Empty, Enumerable.Empty()); + + sut.Emit(evt); + + hint.Should().NotBeNull(); + hint.Items[HintTypes.Exception].Should().BeSameAs(expectedException); + } + [Fact] public void Emit_SerilogSdk_Name() { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 5f150f965e..56ca748572 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -125,6 +125,7 @@ namespace Sentry public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { + public const string Exception = "exception"; public const string HttpResponseMessage = "http-response-message"; } public readonly struct HttpStatusCodeRange : System.IEquatable @@ -149,6 +150,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } + public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category, string? type, System.Collections.Generic.IDictionary? data, Sentry.BreadcrumbLevel level, Sentry.SentryHint? hint) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, bool handled, bool terminal, System.Action configureScope) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 5f150f965e..56ca748572 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -125,6 +125,7 @@ namespace Sentry public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { + public const string Exception = "exception"; public const string HttpResponseMessage = "http-response-message"; } public readonly struct HttpStatusCodeRange : System.IEquatable @@ -149,6 +150,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } + public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category, string? type, System.Collections.Generic.IDictionary? data, Sentry.BreadcrumbLevel level, Sentry.SentryHint? hint) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, bool handled, bool terminal, System.Action configureScope) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 5f150f965e..56ca748572 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -125,6 +125,7 @@ namespace Sentry public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { + public const string Exception = "exception"; public const string HttpResponseMessage = "http-response-message"; } public readonly struct HttpStatusCodeRange : System.IEquatable @@ -149,6 +150,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } + public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category, string? type, System.Collections.Generic.IDictionary? data, Sentry.BreadcrumbLevel level, Sentry.SentryHint? hint) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, bool handled, bool terminal, System.Action configureScope) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 61369e66c2..49ade0556e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -113,6 +113,7 @@ namespace Sentry } public static class HintTypes { + public const string Exception = "exception"; public const string HttpResponseMessage = "http-response-message"; } public readonly struct HttpStatusCodeRange : System.IEquatable @@ -137,6 +138,7 @@ namespace Sentry public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(this Sentry.IHub hub, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } + public static void AddBreadcrumb(this Sentry.IHub hub, Sentry.Infrastructure.ISystemClock? clock, string message, string? category, string? type, System.Collections.Generic.IDictionary? data, Sentry.BreadcrumbLevel level, Sentry.SentryHint? hint) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, System.Action configureScope) { } public static Sentry.SentryId CaptureException(this Sentry.IHub hub, System.Exception ex, bool handled, bool terminal, System.Action configureScope) { } public static Sentry.SentryId CaptureFeedback(this Sentry.IHub hub, Sentry.SentryFeedback feedback, System.Action configureScope, Sentry.SentryHint? hint = null) { } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index d986bd4f95..07e4ded3d5 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -313,6 +313,47 @@ public void CaptureEvent_Exception_LeavesBreadcrumb(bool withScopeCallback) breadcrumb.Category.Should().Be("Exception"); } + [Fact] + public void CaptureEvent_Exception_BreadcrumbHintContainsException() + { + // Arrange + SentryHint hint = null; + _fixture.Options.SetBeforeBreadcrumb((breadcrumb, h) => + { + hint = h; + return breadcrumb; + }); + using var hub = _fixture.GetSut(); + var exception = new Exception("original"); + + // Act + hub.CaptureEvent(new SentryEvent(exception)); + + // Assert + hint.Should().NotBeNull(); + hint.Items[HintTypes.Exception].Should().BeSameAs(exception); + } + + [Fact] + public void CaptureEvent_Exception_BeforeBreadcrumbCanFilterOnExceptionType() + { + // Arrange + _fixture.Options.SetBeforeBreadcrumb((breadcrumb, hint) => + hint.Items.TryGetValue(HintTypes.Exception, out var exception) && exception is InvalidOperationException + ? null + : breadcrumb); + using var hub = _fixture.GetSut(); + var scope = hub.ScopeManager.GetCurrent().Key; + + // Act + hub.CaptureEvent(new SentryEvent(new InvalidOperationException("filtered"))); + hub.CaptureEvent(new SentryEvent(new Exception("kept"))); + + // Assert + scope.Breadcrumbs.Should().ContainSingle(b => b.Category == "Exception") + .Which.Message.Should().Be("kept"); + } + [Fact] public void CaptureEvent_WithMessageAndException_StoresExceptionMessageAsData() {