From 0e21e0a847996aada567668f720fb4495e9e0378 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 7 Oct 2026 16:37:44 +1300 Subject: [PATCH 1/4] fix: count exception filter and exception processor failures as callback_error, and stop dropping events when an SDK processor fails A throwing IExceptionFilter or ISentryEventExceptionProcessor now logs an error naming it, drops the event and records callback_error, rather than falling through to CaptureEvent's catch-all. SDK-owned event, exception and transaction processors implement an internal ISdkProcessor marker. When one throws, the error is logged and processing continues, so an SDK bug neither drops the user's data nor shows up in their Stats as their own callback failing. Closes #5684 Closes #5685 Co-Authored-By: Claude Opus 5.5 --- .../SystemWebRequestEventProcessor.cs | 2 +- .../SentryBuilderExtensions.cs | 3 +- .../AspNetCoreEventProcessor.cs | 3 +- .../AspNetCoreExceptionProcessor.cs | 3 +- .../ExceptionHandlerFeatureProcessor.cs | 3 +- .../DbConcurrencyExceptionProcessor.cs | 4 +- .../DbEntityValidationExceptionProcessor.cs | 4 +- .../SentryStartup.cs | 3 +- .../Internal/SentryMauiEventProcessor.cs | 3 +- .../Internal/SentryMauiScreenshotProcessor.cs | 3 +- .../OpenTelemetryTransactionProcessor.cs | 2 +- .../SerilogScopeEventProcessor.cs | 2 +- .../DuplicateEventDetectionEventProcessor.cs | 2 +- src/Sentry/Internal/ISdkProcessor.cs | 4 + src/Sentry/Internal/MainExceptionProcessor.cs | 2 +- .../Internal/MainSentryEventProcessor.cs | 2 +- src/Sentry/Internal/SentryEventHelper.cs | 4 + ...aceIgnoreStatusCodeTransactionProcessor.cs | 2 +- .../NetFxInstallationsEventProcessor.cs | 3 +- .../Android/AndroidEventProcessor.cs | 3 +- .../Android/LogCatAttachmentEventProcessor.cs | 3 +- .../Platforms/Cocoa/CocoaEventProcessor.cs | 3 +- src/Sentry/SentryClient.cs | 48 ++++++- test/Sentry.Tests/SentryClientTests.cs | 120 ++++++++++++++++++ 24 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 src/Sentry/Internal/ISdkProcessor.cs diff --git a/src/Sentry.AspNet/Internal/SystemWebRequestEventProcessor.cs b/src/Sentry.AspNet/Internal/SystemWebRequestEventProcessor.cs index ba4cfbd735..229c855353 100644 --- a/src/Sentry.AspNet/Internal/SystemWebRequestEventProcessor.cs +++ b/src/Sentry.AspNet/Internal/SystemWebRequestEventProcessor.cs @@ -4,7 +4,7 @@ namespace Sentry.AspNet.Internal; -internal class SystemWebRequestEventProcessor : ISentryEventProcessor +internal class SystemWebRequestEventProcessor : ISentryEventProcessor, ISdkProcessor { private static readonly SdkVersion SdkVersion = typeof(SystemWebRequestEventProcessor).Assembly.GetNameAndVersion(); diff --git a/src/Sentry.AspNetCore.Grpc/SentryBuilderExtensions.cs b/src/Sentry.AspNetCore.Grpc/SentryBuilderExtensions.cs index 76df59bdaf..17457a5cc1 100644 --- a/src/Sentry.AspNetCore.Grpc/SentryBuilderExtensions.cs +++ b/src/Sentry.AspNetCore.Grpc/SentryBuilderExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Sentry.Extensibility; +using Sentry.Internal; using Sentry.Reflection; namespace Sentry.AspNetCore.Grpc; @@ -28,7 +29,7 @@ public static ISentryBuilder AddGrpc(this ISentryBuilder builder) return builder; } - private class SentryGrpcEventProcessor : ISentryEventProcessor + private class SentryGrpcEventProcessor : ISentryEventProcessor, ISdkProcessor { private static readonly SdkVersion NameAndVersion = typeof(SentryGrpcInterceptor).Assembly.GetNameAndVersion(); diff --git a/src/Sentry.AspNetCore/AspNetCoreEventProcessor.cs b/src/Sentry.AspNetCore/AspNetCoreEventProcessor.cs index 0851991710..78d41e90e6 100644 --- a/src/Sentry.AspNetCore/AspNetCoreEventProcessor.cs +++ b/src/Sentry.AspNetCore/AspNetCoreEventProcessor.cs @@ -1,8 +1,9 @@ using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.AspNetCore; -internal class AspNetCoreEventProcessor : ISentryEventProcessor +internal class AspNetCoreEventProcessor : ISentryEventProcessor, ISdkProcessor { public SentryEvent Process(SentryEvent @event) { diff --git a/src/Sentry.AspNetCore/AspNetCoreExceptionProcessor.cs b/src/Sentry.AspNetCore/AspNetCoreExceptionProcessor.cs index ace6284570..261ebe01c2 100644 --- a/src/Sentry.AspNetCore/AspNetCoreExceptionProcessor.cs +++ b/src/Sentry.AspNetCore/AspNetCoreExceptionProcessor.cs @@ -1,9 +1,10 @@ using Sentry.Extensibility; +using Sentry.Internal; using Sentry.Protocol; namespace Sentry.AspNetCore; -internal class AspNetCoreExceptionProcessor : ISentryEventExceptionProcessor +internal class AspNetCoreExceptionProcessor : ISentryEventExceptionProcessor, ISdkProcessor { public void Process(Exception exception, SentryEvent @event) { diff --git a/src/Sentry.AspNetCore/ExceptionHandlerFeatureProcessor.cs b/src/Sentry.AspNetCore/ExceptionHandlerFeatureProcessor.cs index 095accabae..97091ada53 100644 --- a/src/Sentry.AspNetCore/ExceptionHandlerFeatureProcessor.cs +++ b/src/Sentry.AspNetCore/ExceptionHandlerFeatureProcessor.cs @@ -1,11 +1,12 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Routing; using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.AspNetCore; #if NET6_0_OR_GREATER -internal class ExceptionHandlerFeatureProcessor : ISentryEventExceptionProcessor +internal class ExceptionHandlerFeatureProcessor : ISentryEventExceptionProcessor, ISdkProcessor { private readonly string _originalMethod; private readonly IExceptionHandlerFeature _exceptionHandlerFeature; diff --git a/src/Sentry.EntityFramework/ErrorProcessors/DbConcurrencyExceptionProcessor.cs b/src/Sentry.EntityFramework/ErrorProcessors/DbConcurrencyExceptionProcessor.cs index 4495350bda..83f66eb3ea 100644 --- a/src/Sentry.EntityFramework/ErrorProcessors/DbConcurrencyExceptionProcessor.cs +++ b/src/Sentry.EntityFramework/ErrorProcessors/DbConcurrencyExceptionProcessor.cs @@ -1,9 +1,11 @@ +using Sentry.Internal; + namespace Sentry.EntityFramework.ErrorProcessors; /// /// Exception processor for Entity Framework . /// -public class DbConcurrencyExceptionProcessor : SentryEventExceptionProcessor +public class DbConcurrencyExceptionProcessor : SentryEventExceptionProcessor, ISdkProcessor { /// /// Extracts RowCount and RowError from . diff --git a/src/Sentry.EntityFramework/ErrorProcessors/DbEntityValidationExceptionProcessor.cs b/src/Sentry.EntityFramework/ErrorProcessors/DbEntityValidationExceptionProcessor.cs index 3979220318..f5917f329d 100644 --- a/src/Sentry.EntityFramework/ErrorProcessors/DbEntityValidationExceptionProcessor.cs +++ b/src/Sentry.EntityFramework/ErrorProcessors/DbEntityValidationExceptionProcessor.cs @@ -1,9 +1,11 @@ +using Sentry.Internal; + namespace Sentry.EntityFramework.ErrorProcessors; /// /// Exception processor for . /// -public class DbEntityValidationExceptionProcessor : SentryEventExceptionProcessor +public class DbEntityValidationExceptionProcessor : SentryEventExceptionProcessor, ISdkProcessor { /// /// Extracts details from into the . diff --git a/src/Sentry.Google.Cloud.Functions/SentryStartup.cs b/src/Sentry.Google.Cloud.Functions/SentryStartup.cs index ad7e485043..b4b6ce4b82 100644 --- a/src/Sentry.Google.Cloud.Functions/SentryStartup.cs +++ b/src/Sentry.Google.Cloud.Functions/SentryStartup.cs @@ -8,6 +8,7 @@ using Sentry; using Sentry.AspNetCore; using Sentry.Extensibility; +using Sentry.Internal; using Sentry.Reflection; namespace Google.Cloud.Functions.Framework; @@ -94,7 +95,7 @@ public override void Configure(WebHostBuilderContext context, IApplicationBuilde app.UseSentryTracing(); } - private class SentryGoogleCloudFunctionEventProcessor : ISentryEventProcessor + private class SentryGoogleCloudFunctionEventProcessor : ISentryEventProcessor, ISdkProcessor { private static readonly SdkVersion NameAndVersion = typeof(SentryStartup).Assembly.GetNameAndVersion(); diff --git a/src/Sentry.Maui/Internal/SentryMauiEventProcessor.cs b/src/Sentry.Maui/Internal/SentryMauiEventProcessor.cs index 6048da72fc..1e50972cd2 100644 --- a/src/Sentry.Maui/Internal/SentryMauiEventProcessor.cs +++ b/src/Sentry.Maui/Internal/SentryMauiEventProcessor.cs @@ -1,8 +1,9 @@ using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.Maui.Internal; -internal class SentryMauiEventProcessor : ISentryEventProcessor +internal class SentryMauiEventProcessor : ISentryEventProcessor, ISdkProcessor { public static bool? InForeground { get; set; } diff --git a/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs b/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs index 621b6ab95b..07df59d5ef 100644 --- a/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs +++ b/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs @@ -1,8 +1,9 @@ using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.Maui.Internal; -internal class SentryMauiScreenshotProcessor : ISentryEventProcessorWithHint +internal class SentryMauiScreenshotProcessor : ISentryEventProcessorWithHint, ISdkProcessor { private readonly SentryMauiOptions _options; diff --git a/src/Sentry.OpenTelemetry/OpenTelemetryTransactionProcessor.cs b/src/Sentry.OpenTelemetry/OpenTelemetryTransactionProcessor.cs index d7d285eebb..36912d8338 100644 --- a/src/Sentry.OpenTelemetry/OpenTelemetryTransactionProcessor.cs +++ b/src/Sentry.OpenTelemetry/OpenTelemetryTransactionProcessor.cs @@ -3,7 +3,7 @@ namespace Sentry.OpenTelemetry; -internal class OpenTelemetryTransactionProcessor : ISentryTransactionProcessor +internal class OpenTelemetryTransactionProcessor : ISentryTransactionProcessor, ISdkProcessor { public SentryTransaction Process(SentryTransaction transaction) { diff --git a/src/Sentry.Serilog/SerilogScopeEventProcessor.cs b/src/Sentry.Serilog/SerilogScopeEventProcessor.cs index 4b3de83fe0..0951cf816d 100644 --- a/src/Sentry.Serilog/SerilogScopeEventProcessor.cs +++ b/src/Sentry.Serilog/SerilogScopeEventProcessor.cs @@ -5,7 +5,7 @@ namespace Sentry.Serilog; /// /// Sentry event processor that applies properties from the Serilog scope to Sentry events. /// -internal class SerilogScopeEventProcessor : ISentryEventProcessor +internal class SerilogScopeEventProcessor : ISentryEventProcessor, ISdkProcessor { private readonly SentryOptions _options; diff --git a/src/Sentry/Internal/DuplicateEventDetectionEventProcessor.cs b/src/Sentry/Internal/DuplicateEventDetectionEventProcessor.cs index 8e781a46a9..4025b3d9bf 100644 --- a/src/Sentry/Internal/DuplicateEventDetectionEventProcessor.cs +++ b/src/Sentry/Internal/DuplicateEventDetectionEventProcessor.cs @@ -2,7 +2,7 @@ namespace Sentry.Internal; -internal class DuplicateEventDetectionEventProcessor : ISentryEventProcessor +internal class DuplicateEventDetectionEventProcessor : ISentryEventProcessor, ISdkProcessor { private readonly SentryOptions _options; private readonly ConditionalWeakTable _capturedObjects = new(); diff --git a/src/Sentry/Internal/ISdkProcessor.cs b/src/Sentry/Internal/ISdkProcessor.cs new file mode 100644 index 0000000000..2f3e895bd1 --- /dev/null +++ b/src/Sentry/Internal/ISdkProcessor.cs @@ -0,0 +1,4 @@ +namespace Sentry.Internal; + +// Must not be applied to processors that run user code, such as DelegateEventProcessor. +internal interface ISdkProcessor; diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index 8f4899763c..b8d480821d 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -4,7 +4,7 @@ namespace Sentry.Internal; -internal class MainExceptionProcessor : ISentryEventExceptionProcessor +internal class MainExceptionProcessor : ISentryEventExceptionProcessor, ISdkProcessor { private const string ExceptionDataKeyPrefix = "sentry:"; internal const string ExceptionDataTagKey = ExceptionDataKeyPrefix + "tag:"; diff --git a/src/Sentry/Internal/MainSentryEventProcessor.cs b/src/Sentry/Internal/MainSentryEventProcessor.cs index c0259d74b2..6b9c6c0db5 100644 --- a/src/Sentry/Internal/MainSentryEventProcessor.cs +++ b/src/Sentry/Internal/MainSentryEventProcessor.cs @@ -3,7 +3,7 @@ namespace Sentry.Internal; -internal class MainSentryEventProcessor : ISentryEventProcessor +internal class MainSentryEventProcessor : ISentryEventProcessor, ISdkProcessor { internal const string CultureInfoKey = "Current Culture"; internal const string CurrentUiCultureKey = "Current UI Culture"; diff --git a/src/Sentry/Internal/SentryEventHelper.cs b/src/Sentry/Internal/SentryEventHelper.cs index cc12cbb40c..54e8af85cd 100644 --- a/src/Sentry/Internal/SentryEventHelper.cs +++ b/src/Sentry/Internal/SentryEventHelper.cs @@ -21,6 +21,10 @@ internal static class SentryEventHelper { processedEvent = processor.DoProcessEvent(processedEvent, effectiveHint); } + catch (Exception e) when (processor is ISdkProcessor) + { + options.LogError(e, "Event processor {0} threw an exception. Continuing with the remaining processors.", processor.GetType().Name); + } catch (Exception e) { options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, dataCategory); diff --git a/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs b/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs index a6dc881562..3dfe4a39a2 100644 --- a/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs +++ b/src/Sentry/Internal/TraceIgnoreStatusCodeTransactionProcessor.cs @@ -3,7 +3,7 @@ namespace Sentry.Internal; -internal class TraceIgnoreStatusCodeTransactionProcessor : ISentryTransactionProcessor +internal class TraceIgnoreStatusCodeTransactionProcessor : ISentryTransactionProcessor, ISdkProcessor { private const string IncomingRequestOperation = "http.server"; diff --git a/src/Sentry/PlatformAbstractions/NetFxInstallationsEventProcessor.cs b/src/Sentry/PlatformAbstractions/NetFxInstallationsEventProcessor.cs index dd57ccd646..2ec131658f 100644 --- a/src/Sentry/PlatformAbstractions/NetFxInstallationsEventProcessor.cs +++ b/src/Sentry/PlatformAbstractions/NetFxInstallationsEventProcessor.cs @@ -1,9 +1,10 @@ #if NETFRAMEWORK using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.PlatformAbstractions; -internal class NetFxInstallationsEventProcessor : ISentryEventProcessor +internal class NetFxInstallationsEventProcessor : ISentryEventProcessor, ISdkProcessor { internal static readonly string NetFxInstallationsKey = ".NET Framework"; diff --git a/src/Sentry/Platforms/Android/AndroidEventProcessor.cs b/src/Sentry/Platforms/Android/AndroidEventProcessor.cs index cba15fd78e..c95f226641 100644 --- a/src/Sentry/Platforms/Android/AndroidEventProcessor.cs +++ b/src/Sentry/Platforms/Android/AndroidEventProcessor.cs @@ -1,10 +1,11 @@ using Sentry.Android.Extensions; using Sentry.Extensibility; +using Sentry.Internal; using Sentry.JavaSdk.Android.Core; namespace Sentry.Android; -internal class AndroidEventProcessor : ISentryEventProcessor, IDisposable +internal class AndroidEventProcessor : ISentryEventProcessor, IDisposable, ISdkProcessor { private readonly JavaSdk.IEventProcessor? _androidProcessor; private readonly JavaSdk.Hint _hint = new(); diff --git a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs index 89879f813d..2da3e28bdd 100644 --- a/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs +++ b/src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs @@ -1,9 +1,10 @@ using Sentry.Extensibility; +using Sentry.Internal; using Runtime = Java.Lang.Runtime; namespace Sentry.Android; -internal class LogCatAttachmentEventProcessor : ISentryEventProcessorWithHint +internal class LogCatAttachmentEventProcessor : ISentryEventProcessorWithHint, ISdkProcessor { private static bool SendLogcatLogs = true; private readonly LogCatIntegrationType _logCatIntegrationType; diff --git a/src/Sentry/Platforms/Cocoa/CocoaEventProcessor.cs b/src/Sentry/Platforms/Cocoa/CocoaEventProcessor.cs index 03d45e81c9..a122e9ee07 100644 --- a/src/Sentry/Platforms/Cocoa/CocoaEventProcessor.cs +++ b/src/Sentry/Platforms/Cocoa/CocoaEventProcessor.cs @@ -1,9 +1,10 @@ using Sentry.Cocoa.Extensions; using Sentry.Extensibility; +using Sentry.Internal; namespace Sentry.Cocoa; -internal class CocoaEventProcessor : ISentryEventProcessor, IDisposable +internal class CocoaEventProcessor : ISentryEventProcessor, IDisposable, ISdkProcessor { public SentryEvent Process(SentryEvent @event) { diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index d49ac28b5b..a544476d31 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -216,6 +216,10 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent { processedTransaction = processor.DoProcessTransaction(processedTransaction, hint); } + catch (Exception e) when (processor is ISdkProcessor) + { + _options.LogError(e, "Transaction processor {0} threw an exception. Continuing with the remaining processors.", processor.GetType().Name); + } catch (Exception e) { _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Transaction); @@ -331,7 +335,17 @@ public SentryId CaptureCheckIn( private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) { - var filteredExceptions = ApplyExceptionFilters(@event.Exception); + IReadOnlyCollection? filteredExceptions; + try + { + filteredExceptions = ApplyExceptionFilters(@event.Exception); + } + catch (Exception) + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + return SentryId.Empty; + } + if (filteredExceptions?.Count > 0) { _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Error); @@ -363,9 +377,20 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) // after the SDK is initialized. Useful for example once a DI container is up foreach (var processor in scope.GetAllExceptionProcessors()) { - processor.Process(@event.Exception, @event); - - // NOTE: Exception processors can't drop events, but exception filters (above) can. + try + { + processor.Process(@event.Exception, @event); + } + catch (Exception e) when (processor is ISdkProcessor) + { + _options.LogError(e, "Exception processor {0} threw an exception. Continuing with the remaining processors.", processor.GetType().Name); + } + catch (Exception e) + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + _options.LogError(e, "Exception processor {0} threw an exception. The event will be dropped.", processor.GetType().Name); + return SentryId.Empty; + } } } @@ -446,7 +471,7 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return null; } - if (filters.Any(f => f.Filter(exception))) + if (filters.Any(f => InvokeExceptionFilter(f, exception))) { // The event should be filtered based on the given exception return new[] { exception }; @@ -467,6 +492,19 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return null; } + private bool InvokeExceptionFilter(IExceptionFilter filter, Exception exception) + { + try + { + return filter.Filter(exception); + } + catch (Exception e) + { + _options.LogError(e, "Exception filter {0} threw an exception. The event will be dropped.", filter.GetType().Name); + throw; + } + } + /// public bool CaptureEnvelope(Envelope envelope) { diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 1eb558c1d4..bebe532387 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -226,6 +226,46 @@ public void CaptureEvent_ExceptionProcessorsOnScope_Invoked() exceptionProcessor.Received(1).Process(evt.Exception!, evt); } + [Fact] + public void CaptureEvent_ExceptionProcessorThrows_DropsEventAndRecordsDiscard() + { + var exception = new InvalidOperationException("processor failed"); + var processor = Substitute.For(); + processor.When(p => p.Process(Arg.Any(), Arg.Any())).Throw(exception); + _fixture.SentryOptions.AddExceptionProcessor(processor); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + + var id = _fixture.GetSut().CaptureEvent(new SentryEvent(new Exception())); + + id.Should().Be(SentryId.Empty); + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + _fixture.SentryOptions.ReceivedLogError(exception, "Exception processor {0} threw an exception. The event will be dropped."); + } + + [Fact] + public void CaptureEvent_SdkExceptionProcessorThrows_SendsEventWithoutDiscard() + { + var exception = new InvalidOperationException("processor failed"); + var sdkProcessor = Substitute.For(); + sdkProcessor.When(p => p.Process(Arg.Any(), Arg.Any())).Throw(exception); + var nextProcessor = Substitute.For(); + _fixture.SentryOptions.AddExceptionProcessor(sdkProcessor); + _fixture.SentryOptions.AddExceptionProcessor(nextProcessor); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + var evt = new SentryEvent(new Exception()); + + var id = _fixture.GetSut().CaptureEvent(evt); + + id.Should().Be(evt.EventId); + nextProcessor.Received(1).Process(evt.Exception!, evt); + _ = _fixture.BackgroundWorker.Received(1).EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.DidNotReceive() + .RecordDiscardedEvent(Arg.Any(), Arg.Any(), Arg.Any()); + _fixture.SentryOptions.ReceivedLogError(exception, "Exception processor {0} threw an exception. Continuing with the remaining processors."); + } + [Fact] public void CaptureEvent_NullEventWithScope_EmptyGuid() { @@ -434,6 +474,42 @@ public void CaptureEvent_EventProcessorThrows_DropsEventAndRecordsDiscard() .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); } + [Fact] + public void CaptureEvent_DelegateEventProcessorThrows_DropsEventAndRecordsDiscard() + { + var scope = new Scope(_fixture.SentryOptions); + scope.AddEventProcessor(_ => throw new InvalidOperationException()); + + var id = _fixture.GetSut().CaptureEvent(new SentryEvent(), scope); + + id.Should().Be(SentryId.Empty); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + } + + [Fact] + public void CaptureEvent_SdkEventProcessorThrows_SendsEventWithoutDiscard() + { + var exception = new InvalidOperationException("processor failed"); + var sdkProcessor = Substitute.For(); + sdkProcessor.Process(Arg.Any()).Throws(exception); + var nextProcessor = Substitute.For(); + nextProcessor.Process(Arg.Any()).Returns(c => c.Arg()); + _fixture.SentryOptions.AddEventProcessor(sdkProcessor); + _fixture.SentryOptions.AddEventProcessor(nextProcessor); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + var evt = new SentryEvent(); + + var id = _fixture.GetSut().CaptureEvent(evt); + + id.Should().Be(evt.EventId); + nextProcessor.Received(1).Process(evt); + _ = _fixture.BackgroundWorker.Received(1).EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.DidNotReceive() + .RecordDiscardedEvent(Arg.Any(), Arg.Any(), Arg.Any()); + _fixture.SentryOptions.ReceivedLogError(exception, "Event processor {0} threw an exception. Continuing with the remaining processors."); + } + [Fact] public void CaptureEvent_ExceptionFilter_RecordsDiscard() { @@ -449,6 +525,24 @@ public void CaptureEvent_ExceptionFilter_RecordsDiscard() .RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Error); } + [Fact] + public void CaptureEvent_ExceptionFilterThrows_DropsEventAndRecordsDiscard() + { + var exception = new InvalidOperationException("filter failed"); + var filter = Substitute.For(); + filter.Filter(Arg.Any()).Throws(exception); + _fixture.SentryOptions.AddExceptionFilter(filter); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + + var id = _fixture.GetSut().CaptureException(new Exception()); + + id.Should().Be(SentryId.Empty); + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + _fixture.SentryOptions.ReceivedLogError(exception, "Exception filter {0} threw an exception. The event will be dropped."); + } + [Fact] public void CaptureEvent_BeforeSend_GetsHint() { @@ -1803,6 +1897,32 @@ public void CaptureTransaction_TransactionProcessorThrows_DropsTransactionAndRec _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Span, expectedDroppedSpanCount); } + [Fact] + public void CaptureTransaction_SdkTransactionProcessorThrows_SendsTransactionWithoutDiscard() + { + // Arrange + var exception = new InvalidOperationException("processor failed"); + var sdkProcessor = Substitute.For(); + sdkProcessor.Process(Arg.Any()).Throws(exception); + _fixture.SentryOptions.AddTransactionProcessor(sdkProcessor); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + + var tracer = new TransactionTracer(Substitute.For(), "test name", "test operation") + { + EndTimestamp = DateTimeOffset.Now // finished + }; + var transaction = new SentryTransaction(tracer) { IsSampled = true }; + + // Act + _fixture.GetSut().CaptureTransaction(transaction); + + // Assert + _ = _fixture.BackgroundWorker.Received(1).EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.DidNotReceive() + .RecordDiscardedEvent(Arg.Any(), Arg.Any(), Arg.Any()); + _fixture.SentryOptions.ReceivedLogError(exception, "Transaction processor {0} threw an exception. Continuing with the remaining processors."); + } + [Fact] public void CaptureTransaction_BeforeSendTransactionThrows_DropsTransactionAndRecordsDiscard() { From fd718b76ad4793dcdf48658c5683c5854719970c Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 7 Oct 2026 17:14:19 +1300 Subject: [PATCH 2/4] Apply suggestion from @jamescrosswell --- src/Sentry/Internal/ISdkProcessor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Sentry/Internal/ISdkProcessor.cs b/src/Sentry/Internal/ISdkProcessor.cs index 2f3e895bd1..7f85e22f27 100644 --- a/src/Sentry/Internal/ISdkProcessor.cs +++ b/src/Sentry/Internal/ISdkProcessor.cs @@ -1,4 +1,5 @@ namespace Sentry.Internal; -// Must not be applied to processors that run user code, such as DelegateEventProcessor. +// Marker interface for SDK processors. +// Must not be applied to processors that run user code (e.g. DelegateEventProcessor) internal interface ISdkProcessor; From 6255d05a1156971daf7cb5fb7ba9fb5f515a2bc0 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Sat, 10 Oct 2026 03:17:10 +1300 Subject: [PATCH 3/4] fix: keep the event when the stack trace factory throws ISentryStackTraceFactory can be user-supplied. Now that a failing SDK processor no longer drops the event, a throw from the factory would send an event with no SentryExceptions (MainExceptionProcessor) or without the enricher's SDK, environment and OS/runtime context (MainSentryEventProcessor). Catch it at both call sites, log it, and omit only the stack trace. Co-Authored-By: Claude Opus 5.5 --- .../SentryStackTraceFactoryExtensions.cs | 19 +++++++++++++++++++ src/Sentry/Internal/MainExceptionProcessor.cs | 2 +- .../Internal/MainSentryEventProcessor.cs | 3 ++- .../Internals/MainExceptionProcessorTests.cs | 18 ++++++++++++++++++ .../MainSentryEventProcessorTests.cs | 17 +++++++++++++++++ 5 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/Sentry/Internal/Extensions/SentryStackTraceFactoryExtensions.cs diff --git a/src/Sentry/Internal/Extensions/SentryStackTraceFactoryExtensions.cs b/src/Sentry/Internal/Extensions/SentryStackTraceFactoryExtensions.cs new file mode 100644 index 0000000000..e51f033e1c --- /dev/null +++ b/src/Sentry/Internal/Extensions/SentryStackTraceFactoryExtensions.cs @@ -0,0 +1,19 @@ +using Sentry.Extensibility; + +namespace Sentry.Internal.Extensions; + +internal static class SentryStackTraceFactoryExtensions +{ + public static SentryStackTrace? TryCreate(this ISentryStackTraceFactory factory, SentryOptions options, Exception? exception = null) + { + try + { + return factory.Create(exception); + } + catch (Exception e) + { + options.LogError(e, "Stack trace factory {0} threw an exception. The stack trace will be omitted.", factory.GetType().Name); + return null; + } + } +} diff --git a/src/Sentry/Internal/MainExceptionProcessor.cs b/src/Sentry/Internal/MainExceptionProcessor.cs index b8d480821d..c53d644274 100644 --- a/src/Sentry/Internal/MainExceptionProcessor.cs +++ b/src/Sentry/Internal/MainExceptionProcessor.cs @@ -155,7 +155,7 @@ private SentryException BuildSentryException(Exception exception, int id, int? p sentryEx.Mechanism = mechanism; } - sentryEx.Stacktrace ??= SentryStackTraceFactoryAccessor().Create(exception); + sentryEx.Stacktrace ??= SentryStackTraceFactoryAccessor().TryCreate(_options, exception); return sentryEx; } diff --git a/src/Sentry/Internal/MainSentryEventProcessor.cs b/src/Sentry/Internal/MainSentryEventProcessor.cs index 6b9c6c0db5..7d7b1ed0a5 100644 --- a/src/Sentry/Internal/MainSentryEventProcessor.cs +++ b/src/Sentry/Internal/MainSentryEventProcessor.cs @@ -1,4 +1,5 @@ using Sentry.Extensibility; +using Sentry.Internal.Extensions; using Sentry.Reflection; namespace Sentry.Internal; @@ -87,7 +88,7 @@ public SentryEvent Process(SentryEvent @event) if (@event.Exception?.StackTrace is null && @event.SentryThreads?.Any(t => t.Current == true) != true) { var stackTrace = @event.SentryExceptions?.FirstOrDefault()?.Stacktrace - ?? SentryStackTraceFactoryAccessor().Create(); + ?? SentryStackTraceFactoryAccessor().TryCreate(_options); if (stackTrace != null) { var currentThread = Thread.CurrentThread; diff --git a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs index 9a65da4dbf..215697db68 100644 --- a/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainExceptionProcessorTests.cs @@ -47,6 +47,24 @@ public void Process_ExceptionsWithData_MechanismDataIsPopulated() Assert.Contains(data, pair => pair.Key == "bar" && pair.Value.Equals(456)); } + [Fact] + public void Process_StackTraceFactoryThrows_KeepsExceptionWithoutStackTrace() + { + var factoryException = new InvalidOperationException("factory failed"); + _fixture.SentryStackTraceFactory.Create(Arg.Any()).Throws(factoryException); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + var sut = _fixture.GetSut(); + var evt = new SentryEvent(); + + sut.Process(new InvalidOperationException("captured"), evt); + + var sentryException = evt.SentryExceptions.Should().ContainSingle().Subject; + sentryException.Type.Should().Be(typeof(InvalidOperationException).FullName); + sentryException.Value.Should().Be("captured"); + sentryException.Stacktrace.Should().BeNull(); + _fixture.SentryOptions.ReceivedLogError(factoryException, "Stack trace factory {0} threw an exception. The stack trace will be omitted."); + } + [Fact] public void Process_ExceptionWithout_Handled() { diff --git a/test/Sentry.Tests/Internals/MainSentryEventProcessorTests.cs b/test/Sentry.Tests/Internals/MainSentryEventProcessorTests.cs index 89e3a4379f..17ae308278 100644 --- a/test/Sentry.Tests/Internals/MainSentryEventProcessorTests.cs +++ b/test/Sentry.Tests/Internals/MainSentryEventProcessorTests.cs @@ -35,6 +35,23 @@ public void Process_SendDefaultPiiTrueIdEnvironmentDefault_UserNameSet() Assert.Equal(Environment.UserName, evt.User.Username); } + [Fact] + public void Process_StackTraceFactoryThrows_StillEnrichesEvent() + { + var factoryException = new InvalidOperationException("factory failed"); + _fixture.SentryStackTraceFactory.Create(Arg.Any()).Throws(factoryException); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + var sut = _fixture.GetSut(); + var evt = new SentryEvent(); + + _ = sut.Process(evt); + + evt.SentryThreads.Should().BeNullOrEmpty(); + evt.Sdk.Name.Should().Be(Sentry.Internal.Constants.SdkName); + evt.Environment.Should().NotBeNull(); + _fixture.SentryOptions.ReceivedLogError(factoryException, "Stack trace factory {0} threw an exception. The stack trace will be omitted."); + } + [Fact] public void EnsureThreadPoolInfoExists() { From f2796cf0fa92c55d314ad277ece511ec111e5047 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Sat, 10 Oct 2026 03:34:07 +1300 Subject: [PATCH 4/4] ref: handle a throwing exception filter in one place ApplyExceptionFilters now catches only the filter call itself, logs and records callback_error there, and reports the failure through an out parameter. SDK code in the filtering path is no longer caught and miscounted as the user's callback_error; it reaches CaptureEvent's catch-all as before. Also cover a throwing scope.AddTransactionProcessor lambda, so marking DelegateTransactionProcessor as an SDK processor would fail a test. Co-Authored-By: Claude Opus 5.5 --- src/Sentry/SentryClient.cs | 55 +++++++++++++------------- test/Sentry.Tests/SentryClientTests.cs | 41 +++++++++++++++++++ 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index a544476d31..09d9a4c3e5 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -335,14 +335,9 @@ public SentryId CaptureCheckIn( private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) { - IReadOnlyCollection? filteredExceptions; - try - { - filteredExceptions = ApplyExceptionFilters(@event.Exception); - } - catch (Exception) + var filteredExceptions = ApplyExceptionFilters(@event.Exception, out var filterThrew); + if (filterThrew) { - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); return SentryId.Empty; } @@ -462,8 +457,9 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return SentryId.Empty; } - private IReadOnlyCollection? ApplyExceptionFilters(Exception? exception) + private IReadOnlyCollection? ApplyExceptionFilters(Exception? exception, out bool filterThrew) { + filterThrew = false; var filters = _options.ExceptionFilters; if (exception == null || filters == null || filters.Count == 0) { @@ -471,40 +467,45 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope) return null; } - if (filters.Any(f => InvokeExceptionFilter(f, exception))) + foreach (var filter in filters) { - // The event should be filtered based on the given exception - return new[] { exception }; + try + { + if (filter.Filter(exception)) + { + // The event should be filtered based on the given exception + return new[] { exception }; + } + } + catch (Exception e) + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + _options.LogError(e, "Exception filter {0} threw an exception. The event will be dropped.", filter.GetType().Name); + filterThrew = true; + return null; + } } if (exception is AggregateException aggregate) { // Flatten the tree of aggregates such that all the inner exceptions are non-aggregates. var innerExceptions = aggregate.Flatten().InnerExceptions; - if (innerExceptions.All(e => ApplyExceptionFilters(e) != null)) + foreach (var innerException in innerExceptions) { - // All inner exceptions matched a filter, so the event should be filtered. - return innerExceptions; + if (ApplyExceptionFilters(innerException, out filterThrew) == null) + { + return null; + } } + + // All inner exceptions matched a filter, so the event should be filtered. + return innerExceptions; } // The event should not be filtered. return null; } - private bool InvokeExceptionFilter(IExceptionFilter filter, Exception exception) - { - try - { - return filter.Filter(exception); - } - catch (Exception e) - { - _options.LogError(e, "Exception filter {0} threw an exception. The event will be dropped.", filter.GetType().Name); - throw; - } - } - /// public bool CaptureEnvelope(Envelope envelope) { diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index bebe532387..9a681933a6 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -543,6 +543,23 @@ public void CaptureEvent_ExceptionFilterThrows_DropsEventAndRecordsDiscard() _fixture.SentryOptions.ReceivedLogError(exception, "Exception filter {0} threw an exception. The event will be dropped."); } + [Fact] + public void CaptureEvent_ExceptionFilterThrowsForInnerException_DropsEventAndRecordsDiscard() + { + var filter = Substitute.For(); + filter.Filter(Arg.Is(e => e is InvalidOperationException)).Throws(new ArgumentException("filter failed")); + _fixture.SentryOptions.AddExceptionFilter(filter); + + var id = _fixture.GetSut().CaptureException(new AggregateException(new InvalidOperationException())); + + id.Should().Be(SentryId.Empty); + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); + _fixture.ClientReportRecorder.DidNotReceive() + .RecordDiscardedEvent(DiscardReason.EventProcessor, Arg.Any(), Arg.Any()); + } + [Fact] public void CaptureEvent_BeforeSend_GetsHint() { @@ -1897,6 +1914,30 @@ public void CaptureTransaction_TransactionProcessorThrows_DropsTransactionAndRec _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Span, expectedDroppedSpanCount); } + [Fact] + public void CaptureTransaction_DelegateTransactionProcessorThrows_DropsTransactionAndRecordsDiscard() + { + // Arrange + var scope = new Scope(_fixture.SentryOptions); + scope.AddTransactionProcessor(_ => throw new InvalidOperationException()); + + var tracer = new TransactionTracer(Substitute.For(), "test name", "test operation") + { + EndTimestamp = DateTimeOffset.Now // finished + }; + var transaction = new SentryTransaction(tracer) { IsSampled = true }; + + // Act + _fixture.GetSut().CaptureTransaction(transaction, scope, null); + + // Assert + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Transaction); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Span, transaction.Spans.Count + 1); + } + [Fact] public void CaptureTransaction_SdkTransactionProcessorThrows_SendsTransactionWithoutDiscard() {