From 5afe8bd940cbc02f90a8d282f5beb8abdcf53eb7 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Sep 2026 12:31:35 +1200 Subject: [PATCH 1/9] fix: exceptions thrown from BeforeBreadcrumb, log filters and BeforeScreenshotCapture no longer reach the application Completes the callback-isolation work item from #5535 that #5545 started for TracesSampler. Each callback now runs inside a recovery boundary that logs an error naming the callback and applies the spec's fallback: - BeforeBreadcrumb (Scope.AddBreadcrumb, Android JNI, Cocoa) drops the breadcrumb - ILogEntryFilter.Filter treats a failing filter as "did not filter" - SetBeforeScreenshotCapture skips the screenshot and keeps the event Co-Authored-By: Claude Opus 5 --- src/Sentry.Extensions.Logging/SentryLogger.cs | 38 +++++++++----- .../Internal/SentryMauiScreenshotProcessor.cs | 18 ++++++- .../Callbacks/BeforeBreadcrumbCallback.cs | 19 ++++++- src/Sentry/Platforms/Android/SentrySdk.cs | 2 +- src/Sentry/Platforms/Cocoa/SentrySdk.cs | 12 ++++- src/Sentry/Scope.cs | 15 ++++-- .../SentryLoggerTests.cs | 31 +++++++++++ .../SentryMauiScreenshotProcessorTests.cs | 52 +++++++++++++++++++ test/Sentry.Tests/ScopeTests.cs | 18 +++++++ 9 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs diff --git a/src/Sentry.Extensions.Logging/SentryLogger.cs b/src/Sentry.Extensions.Logging/SentryLogger.cs index 05013d0fef..c4e6a1630e 100644 --- a/src/Sentry.Extensions.Logging/SentryLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryLogger.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; @@ -161,12 +162,7 @@ private bool ShouldCaptureEvent( && logLevel >= _options.MinimumEventLevel && !IsFromSentry() && !IsEfExceptionMessage(eventId) - && _options.Filters.All( - f => !f.Filter( - CategoryName, - logLevel, - eventId, - exception)); + && !IsFiltered(logLevel, eventId, exception); private bool ShouldAddBreadcrumb( LogLevel logLevel, @@ -176,12 +172,30 @@ private bool ShouldAddBreadcrumb( && logLevel >= _options.MinimumBreadcrumbLevel && !IsFromSentry() && !IsEfExceptionMessage(eventId) - && _options.Filters.All( - f => !f.Filter( - CategoryName, - logLevel, - eventId, - exception)); + && !IsFiltered(logLevel, eventId, exception); + + private bool IsFiltered( + LogLevel logLevel, + EventId eventId, + Exception? exception) + => _options.Filters.Any(f => IsFiltered(f, logLevel, eventId, exception)); + + private bool IsFiltered( + ILogEntryFilter filter, + LogLevel logLevel, + EventId eventId, + Exception? exception) + { + try + { + return filter.Filter(CategoryName, logLevel, eventId, exception); + } + catch (Exception e) + { + _options.LogError(e, "The {0} log filter callback failed. The log entry will not be filtered.", filter.GetType().Name); + return false; + } + } private bool IsFromSentry() => SentrySdkNamespaces.IsSentrySdk(CategoryName); diff --git a/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs b/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs index fbba3d457d..02f2a04b8f 100644 --- a/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs +++ b/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs @@ -18,9 +18,23 @@ public SentryMauiScreenshotProcessor(SentryMauiOptions options) public SentryEvent? Process(SentryEvent @event, SentryHint hint) { - if (!_options.BeforeCaptureInternal?.Invoke(@event, hint) ?? false) + if (_options.BeforeCaptureInternal is { } beforeCapture) { - return @event; + bool shouldCapture; + try + { + shouldCapture = beforeCapture.Invoke(@event, hint); + } + catch (Exception e) + { + _options.LogError(e, "BeforeCaptureScreenshot callback failed."); + return @event; + } + + if (!shouldCapture) + { + return @event; + } } hint.Attachments.Add(new ScreenshotAttachment(_options)); diff --git a/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs b/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs index ac2f149902..88f6c03ae0 100644 --- a/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs +++ b/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs @@ -1,14 +1,19 @@ using Sentry.Android.Extensions; +using Sentry.Extensibility; namespace Sentry.Android.Callbacks; internal class BeforeBreadcrumbCallback : JavaObject, JavaSdk.SentryOptions.IBeforeBreadcrumbCallback { private readonly Func _beforeBreadcrumb; + private readonly SentryOptions _options; - public BeforeBreadcrumbCallback(Func beforeBreadcrumb) + public BeforeBreadcrumbCallback( + Func beforeBreadcrumb, + SentryOptions options) { _beforeBreadcrumb = beforeBreadcrumb; + _options = options; } public JavaSdk.Breadcrumb? Execute(JavaSdk.Breadcrumb b, JavaSdk.Hint h) @@ -18,7 +23,17 @@ public BeforeBreadcrumbCallback(Func before var breadcrumb = b.ToBreadcrumb(); var hint = h.ToHint(); - var result = _beforeBreadcrumb.Invoke(breadcrumb, hint); + + Breadcrumb? result; + try + { + result = _beforeBreadcrumb.Invoke(breadcrumb, hint); + } + catch (Exception exception) + { + _options.LogError(exception, "BeforeBreadcrumb callback failed."); + return null; + } if (result == breadcrumb) { diff --git a/src/Sentry/Platforms/Android/SentrySdk.cs b/src/Sentry/Platforms/Android/SentrySdk.cs index 9afb73dcc8..ab07387924 100644 --- a/src/Sentry/Platforms/Android/SentrySdk.cs +++ b/src/Sentry/Platforms/Android/SentrySdk.cs @@ -113,7 +113,7 @@ private static void InitSentryAndroidSdk(SentryOptions options) if (options.BeforeBreadcrumbInternal is { } beforeBreadcrumb) { - o.BeforeBreadcrumb = new BeforeBreadcrumbCallback(beforeBreadcrumb); + o.BeforeBreadcrumb = new BeforeBreadcrumbCallback(beforeBreadcrumb, options); } // These options we have behind feature flags diff --git a/src/Sentry/Platforms/Cocoa/SentrySdk.cs b/src/Sentry/Platforms/Cocoa/SentrySdk.cs index b55b44b3bf..e44770cebb 100644 --- a/src/Sentry/Platforms/Cocoa/SentrySdk.cs +++ b/src/Sentry/Platforms/Cocoa/SentrySdk.cs @@ -62,7 +62,17 @@ private static void InitSentryCocoaSdk(SentryOptions options) // See https://github.com/getsentry/sentry-cocoa/issues/2325 var hint = new SentryHint(); var breadcrumb = b.ToBreadcrumb(options.DiagnosticLogger); - var result = beforeBreadcrumb(breadcrumb, hint)?.ToCocoaBreadcrumb(); + + CocoaSdk.SentryObjCBreadcrumb? result; + try + { + result = beforeBreadcrumb(breadcrumb, hint)?.ToCocoaBreadcrumb(); + } + catch (Exception ex) + { + options.LogError(ex, "BeforeBreadcrumb callback failed."); + result = null; + } // Note: Nullable result is allowed but delegate is generated incorrectly // See https://github.com/xamarin/xamarin-macios/issues/15299#issuecomment-1201863294 diff --git a/src/Sentry/Scope.cs b/src/Sentry/Scope.cs index d062290133..17b9df38ba 100644 --- a/src/Sentry/Scope.cs +++ b/src/Sentry/Scope.cs @@ -335,15 +335,24 @@ public void AddBreadcrumb(Breadcrumb breadcrumb, SentryHint hint) { hint.AddAttachmentsFromScope(this); - if (beforeBreadcrumb(breadcrumb, hint) is { } processedBreadcrumb) + Breadcrumb? processedBreadcrumb; + try { - breadcrumb = processedBreadcrumb; + processedBreadcrumb = beforeBreadcrumb(breadcrumb, hint); } - else + catch (Exception e) + { + Options.LogError(e, "BeforeBreadcrumb callback failed."); + return; + } + + if (processedBreadcrumb is null) { // Callback returned null, which means the breadcrumb should be dropped return; } + + breadcrumb = processedBreadcrumb; } if (Options.MaxBreadcrumbs <= 0) diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs index 8a748b030a..09aaeea5ff 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs @@ -214,6 +214,37 @@ public void LogCritical_NotMatchingFilter_CapturesEvent() .CaptureEvent(Arg.Any()); } + [Fact] + public void LogCritical_FilterThrows_CapturesEventAndLogsError() + { + var exception = new InvalidOperationException("filter failed"); + _fixture.Options.AddLogEntryFilter((_, _, _, _) => throw exception); + _fixture.Options.AddDiagnosticLoggerSubstitute(); + + var sut = _fixture.GetSut(); + + sut.LogCritical("message"); + + _ = _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + _fixture.Options.ReceivedLogError(exception, + "The {0} log filter callback failed. The log entry will not be filtered.", + nameof(DelegateLogEntryFilter)); + } + + [Fact] + public void LogCritical_FilterThrows_OtherFiltersStillApply() + { + _fixture.Options.AddLogEntryFilter((_, _, _, _) => throw new InvalidOperationException("filter failed")); + _fixture.Options.AddLogEntryFilter((_, _, _, _) => true); + _fixture.Options.AddDiagnosticLoggerSubstitute(); + + var sut = _fixture.GetSut(); + + sut.LogCritical("message"); + + _ = _fixture.Hub.DidNotReceive().CaptureEvent(Arg.Any()); + } + [Fact] public void LogCritical_DefaultOptions_CapturesEvent() { diff --git a/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs b/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs new file mode 100644 index 0000000000..56e1fc1792 --- /dev/null +++ b/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs @@ -0,0 +1,52 @@ +using Sentry.Maui.Internal; + +namespace Sentry.Maui.Tests.Internal; + +public class SentryMauiScreenshotProcessorTests +{ + [Fact] + public void Process_BeforeCaptureScreenshotThrows_KeepsEventAndSkipsScreenshot() + { + // Arrange + var exception = new InvalidOperationException("callback failed"); + var logger = new InMemoryDiagnosticLogger(); + var options = new SentryMauiOptions + { + Debug = true, + DiagnosticLogger = logger + }; + options.SetBeforeScreenshotCapture((_, _) => throw exception); + var processor = new SentryMauiScreenshotProcessor(options); + + var @event = new SentryEvent(); + var hint = new SentryHint(); + + // Act + var processed = processor.Process(@event, hint); + + // Assert + processed.Should().BeSameAs(@event); + hint.Attachments.Should().BeEmpty(); + logger.Entries.Should().ContainSingle(entry => + entry.Level == SentryLevel.Error && + entry.Exception == exception && + entry.Message == "BeforeCaptureScreenshot callback failed."); + } + + [Fact] + public void Process_BeforeCaptureScreenshotReturnsTrue_AddsScreenshot() + { + // Arrange + var options = new SentryMauiOptions(); + options.SetBeforeScreenshotCapture((_, _) => true); + var processor = new SentryMauiScreenshotProcessor(options); + + var hint = new SentryHint(); + + // Act + processor.Process(new SentryEvent(), hint); + + // Assert + hint.Attachments.Should().ContainSingle(a => a.FileName == "screenshot.jpg"); + } +} diff --git a/test/Sentry.Tests/ScopeTests.cs b/test/Sentry.Tests/ScopeTests.cs index 0e04ec4b34..9f47603ced 100644 --- a/test/Sentry.Tests/ScopeTests.cs +++ b/test/Sentry.Tests/ScopeTests.cs @@ -534,6 +534,24 @@ public void AddBreadcrumb_BeforeAddBreadcrumb_ReceivesHint() receivedHint.Should().BeSameAs(expectedHint); } + [Fact] + public void AddBreadcrumb_BeforeBreadcrumbThrows_DropsBreadcrumbAndLogsError() + { + // Arrange + var exception = new InvalidOperationException("callback failed"); + var options = new SentryOptions(); + options.SetBeforeBreadcrumb((_, _) => throw exception); + options.AddDiagnosticLoggerSubstitute(); + var scope = new Scope(options); + + // Act + scope.AddBreadcrumb(new Breadcrumb()); + + // Assert + scope.Breadcrumbs.Should().BeEmpty(); + options.ReceivedLogError(exception, "BeforeBreadcrumb callback failed."); + } + [Fact] public void AddBreadcrumb_ScopeAttachments_Copied_To_Hint() { From 7561f8ded86b6dbdb2170ffae8da58ae7ebd0445 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Sep 2026 13:25:20 +1200 Subject: [PATCH 2/9] ref: name the platform in native BeforeBreadcrumb failure logs, add Android device tests Addresses review feedback on #5606: distinguish native callback failures from managed ones in the diagnostic log. Co-Authored-By: Claude Opus 5 --- .../Callbacks/BeforeBreadcrumbCallback.cs | 2 +- src/Sentry/Platforms/Cocoa/SentrySdk.cs | 2 +- .../Android/BeforeBreadcrumbCallbackTests.cs | 59 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 test/Sentry.Tests/Platforms/Android/BeforeBreadcrumbCallbackTests.cs diff --git a/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs b/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs index 88f6c03ae0..37629e821c 100644 --- a/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs +++ b/src/Sentry/Platforms/Android/Callbacks/BeforeBreadcrumbCallback.cs @@ -31,7 +31,7 @@ public BeforeBreadcrumbCallback( } catch (Exception exception) { - _options.LogError(exception, "BeforeBreadcrumb callback failed."); + _options.LogError(exception, "Android BeforeBreadcrumb callback failed."); return null; } diff --git a/src/Sentry/Platforms/Cocoa/SentrySdk.cs b/src/Sentry/Platforms/Cocoa/SentrySdk.cs index e44770cebb..539614f2e5 100644 --- a/src/Sentry/Platforms/Cocoa/SentrySdk.cs +++ b/src/Sentry/Platforms/Cocoa/SentrySdk.cs @@ -70,7 +70,7 @@ private static void InitSentryCocoaSdk(SentryOptions options) } catch (Exception ex) { - options.LogError(ex, "BeforeBreadcrumb callback failed."); + options.LogError(ex, "Cocoa BeforeBreadcrumb callback failed."); result = null; } diff --git a/test/Sentry.Tests/Platforms/Android/BeforeBreadcrumbCallbackTests.cs b/test/Sentry.Tests/Platforms/Android/BeforeBreadcrumbCallbackTests.cs new file mode 100644 index 0000000000..2480623548 --- /dev/null +++ b/test/Sentry.Tests/Platforms/Android/BeforeBreadcrumbCallbackTests.cs @@ -0,0 +1,59 @@ +#if ANDROID +using Sentry.Android.Callbacks; + +namespace Sentry.Tests.Platforms.Android; + +public class BeforeBreadcrumbCallbackTests +{ + private static JavaSdk.Breadcrumb JavaBreadcrumb() => new() { Message = "test", Category = "test" }; + + [Fact] + public void Execute_CallbackThrows_ReturnsNullAndLogsError() + { + // Arrange + var exception = new InvalidOperationException("callback failed"); + var logger = new InMemoryDiagnosticLogger(); + var options = new SentryOptions { Debug = true, DiagnosticLogger = logger }; + using var sut = new BeforeBreadcrumbCallback((_, _) => throw exception, options); + + // Act + using var result = sut.Execute(JavaBreadcrumb(), new JavaSdk.Hint()); + + // Assert + result.Should().BeNull(); + logger.Entries.Should().ContainSingle(entry => + entry.Level == SentryLevel.Error && + entry.Exception == exception && + entry.Message == "Android BeforeBreadcrumb callback failed."); + } + + [Fact] + public void Execute_CallbackReturnsNull_ReturnsNull() + { + // Arrange + var options = new SentryOptions(); + using var sut = new BeforeBreadcrumbCallback((_, _) => null, options); + + // Act + using var result = sut.Execute(JavaBreadcrumb(), new JavaSdk.Hint()); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Execute_CallbackReturnsInput_ReturnsOriginalJavaBreadcrumb() + { + // Arrange + var options = new SentryOptions(); + using var sut = new BeforeBreadcrumbCallback((breadcrumb, _) => breadcrumb, options); + using var javaBreadcrumb = JavaBreadcrumb(); + + // Act + var result = sut.Execute(javaBreadcrumb, new JavaSdk.Hint()); + + // Assert + result.Should().BeSameAs(javaBreadcrumb); + } +} +#endif From 05efd92d0d83f742ca1f4b9c4588dd7945c7178d Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Sep 2026 13:36:52 +1200 Subject: [PATCH 3/9] fix: record client reports for items dropped by user callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work item 2 of #5535. Items lost to a user callback — whether it returned null or threw — were disappearing without a client report, and a throwing event or transaction processor skipped the report entirely by unwinding to the Hub catch-all. Co-Authored-By: Claude Opus 5 --- .../Internal/DefaultSentryMetricEmitter.cs | 8 +++- .../Internal/DefaultSentryStructuredLogger.cs | 9 +++- src/Sentry/Internal/SentryEventHelper.cs | 12 +++++- src/Sentry/SentryClient.cs | 13 +++++- test/Sentry.Tests/SentryClientTests.cs | 42 +++++++++++++++++++ test/Sentry.Tests/SentryMetricEmitterTests.cs | 5 +++ .../SentryStructuredLoggerTests.cs | 6 +++ 7 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs index 11d2879295..e7a4876f8a 100644 --- a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs +++ b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs @@ -76,15 +76,19 @@ private protected override void CaptureMetric(SentryMetric metric) where T } catch (Exception e) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); _options.DiagnosticLogger?.LogError(e, "The BeforeSendMetric callback threw an exception. The Metric will be dropped."); return; } } - if (configuredMetric is not null) + if (configuredMetric is null) { - _batchProcessor.Enqueue(configuredMetric); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); + return; } + + _batchProcessor.Enqueue(configuredMetric); } /// diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 5c775c775d..947a6c77db 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -70,6 +70,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template } catch (Exception e) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); return; } @@ -93,15 +94,19 @@ protected internal override void CaptureLog(SentryLog log) } catch (Exception e) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); return; } } - if (configuredLog is not null) + if (configuredLog is null) { - _batchProcessor.Enqueue(configuredLog); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + return; } + + _batchProcessor.Enqueue(configuredLog); } /// diff --git a/src/Sentry/Internal/SentryEventHelper.cs b/src/Sentry/Internal/SentryEventHelper.cs index bda07489e0..8c0dad38aa 100644 --- a/src/Sentry/Internal/SentryEventHelper.cs +++ b/src/Sentry/Internal/SentryEventHelper.cs @@ -17,7 +17,17 @@ internal static class SentryEventHelper foreach (var processor in processors) { - processedEvent = processor.DoProcessEvent(processedEvent, effectiveHint); + try + { + processedEvent = processor.DoProcessEvent(processedEvent, effectiveHint); + } + catch (Exception e) + { + options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, dataCategory); + options.LogError(e, "Event processor {0} threw an exception. The event will be dropped.", processor.GetType().Name); + return null; + } + if (processedEvent == null) { options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, dataCategory); diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index c3c88a062e..64e82e037f 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -212,7 +212,18 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent var processedTransaction = transaction; foreach (var processor in scope.GetAllTransactionProcessors()) { - processedTransaction = processor.DoProcessTransaction(transaction, hint); + try + { + processedTransaction = processor.DoProcessTransaction(transaction, hint); + } + catch (Exception e) + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Transaction); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Span, spanCount); + _options.LogError(e, "Transaction processor {0} threw an exception. The transaction will be dropped.", processor.GetType().Name); + return; + } + if (processedTransaction == null) // Rejected transaction { _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Transaction); diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 8329dad9a1..2eff11188f 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -387,6 +387,23 @@ public void CaptureEvent_EventProcessor_RejectEvent_RecordsDiscard() .RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Error); } + [Fact] + public void CaptureEvent_EventProcessorThrows_DropsEventAndRecordsDiscard() + { + var processor = Substitute.For(); + processor.Process(Arg.Any()).Throws(new InvalidOperationException()); + + _fixture.SentryOptions.AddEventProcessor(processor); + + var sut = _fixture.GetSut(); + var id = sut.CaptureEvent(new SentryEvent()); + + id.Should().Be(SentryId.Empty); + _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Error); + } + [Fact] public void CaptureEvent_ExceptionFilter_RecordsDiscard() { @@ -1695,6 +1712,31 @@ public void CaptureTransaction_TransactionProcessorRejectsEvent_RecordDiscardedE _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Span, expectedDroppedSpanCount); } + [Fact] + public void CaptureTransaction_TransactionProcessorThrows_DropsTransactionAndRecordsDiscard() + { + // Arrange + var processor = Substitute.For(); + processor.Process(Arg.Any(), Arg.Any()).Throws(new InvalidOperationException()); + _fixture.SentryOptions.AddTransactionProcessor(processor); + + var hub = Substitute.For(); + var transaction = new TransactionTracer(hub, "test name", "test operation"); + transaction.StartChild("span1"); + transaction.StartChild("span2"); + transaction.EndTimestamp = DateTimeOffset.Now; // finished + + // Act + _fixture.GetSut().CaptureTransaction(new SentryTransaction(transaction)); + + // Assert + _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + var reason = DiscardReason.EventProcessor; + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Transaction); + var expectedDroppedSpanCount = transaction.Spans.Count + 1; + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Span, expectedDroppedSpanCount); + } + [Fact] public void CaptureTransaction_BeforeSendTransaction_GetsHint() { diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.cs b/test/Sentry.Tests/SentryMetricEmitterTests.cs index 293bad2cdb..32902a70a8 100644 --- a/test/Sentry.Tests/SentryMetricEmitterTests.cs +++ b/test/Sentry.Tests/SentryMetricEmitterTests.cs @@ -13,10 +13,12 @@ public Fixture() { DiagnosticLogger = new InMemoryDiagnosticLogger(); Hub = Substitute.For(); + ClientReportRecorder = Substitute.For(); Options = new SentryOptions { Debug = true, DiagnosticLogger = DiagnosticLogger, + ClientReportRecorder = ClientReportRecorder, }; Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); BatchSize = 2; @@ -39,6 +41,7 @@ public Fixture() public InMemoryDiagnosticLogger DiagnosticLogger { get; } public IHub Hub { get; } + public IClientReportRecorder ClientReportRecorder { get; } public SentryOptions Options { get; } public ISystemClock Clock { get; } public int BatchSize { get; set; } @@ -137,6 +140,7 @@ public void Emit_WhenBeforeSendMetricReturnsNull_DoesNotCaptureEnvelope() _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); invocations.Should().Be(1); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); } [Fact] @@ -153,6 +157,7 @@ public void Emit_InvalidBeforeSendMetric_DoesNotCaptureEnvelope() entry.Message.Should().Be("The BeforeSendMetric callback threw an exception. The Metric will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); } [Fact] diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index f78266898b..33310a0b1c 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -13,10 +13,12 @@ public Fixture() { DiagnosticLogger = new InMemoryDiagnosticLogger(); Hub = Substitute.For(); + ClientReportRecorder = Substitute.For(); Options = new SentryOptions { Debug = true, DiagnosticLogger = DiagnosticLogger, + ClientReportRecorder = ClientReportRecorder, }; Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); BatchSize = 2; @@ -39,6 +41,7 @@ public Fixture() public InMemoryDiagnosticLogger DiagnosticLogger { get; } public IHub Hub { get; } + public IClientReportRecorder ClientReportRecorder { get; } public SentryOptions Options { get; } public ISystemClock Clock { get; } public int BatchSize { get; set; } @@ -148,6 +151,7 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); invocations.Should().Be(1); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); } [Fact] @@ -178,6 +182,7 @@ public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() entry.Message.Should().Be("The configureLog callback threw an exception. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); } [Fact] @@ -194,6 +199,7 @@ public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() entry.Message.Should().Be("The BeforeSendLog callback threw an exception. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); } [Fact] From 6de2c4026cf279dcaa0d48b060999bb786b70c8f Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 23 Sep 2026 13:28:40 +1200 Subject: [PATCH 4/9] fix: record client reports for logs and metrics dropped by validation A log whose template doesn't match its arguments, and a metric with an unsupported value type or an empty name, were dropped with a diagnostic log and no client report. Records the spec's `invalid` reason, which the SDK did not previously carry. Co-Authored-By: Claude Opus 5 --- src/Sentry/Internal/DefaultSentryMetricEmitter.cs | 4 ++++ src/Sentry/Internal/DefaultSentryStructuredLogger.cs | 1 + src/Sentry/Internal/DiscardReason.cs | 1 + test/Sentry.Tests/SentryMetricEmitterTests.Types.cs | 5 +++++ test/Sentry.Tests/SentryStructuredLoggerTests.cs | 1 + 5 files changed, 12 insertions(+) diff --git a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs index e7a4876f8a..04c2e6e4e9 100644 --- a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs +++ b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs @@ -27,12 +27,14 @@ private protected override void CaptureMetric(SentryMetricType type, string n { if (!SentryMetric.IsSupported(typeof(T))) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); _options.DiagnosticLogger?.LogWarning("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double.", typeof(T)); return; } if (string.IsNullOrEmpty(name)) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); _options.DiagnosticLogger?.LogWarning("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}", type.ToString(), typeof(T)); return; } @@ -46,12 +48,14 @@ private protected override void CaptureMetric(SentryMetricType type, string n { if (!SentryMetric.IsSupported(typeof(T))) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); _options.DiagnosticLogger?.LogWarning("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double.", typeof(T)); return; } if (string.IsNullOrEmpty(name)) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); _options.DiagnosticLogger?.LogWarning("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}", type.ToString(), typeof(T)); return; } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 947a6c77db..670ff58ea8 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -39,6 +39,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template } catch (FormatException e) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.LogItem); _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); return; } diff --git a/src/Sentry/Internal/DiscardReason.cs b/src/Sentry/Internal/DiscardReason.cs index a75a24566f..7ed23144ae 100644 --- a/src/Sentry/Internal/DiscardReason.cs +++ b/src/Sentry/Internal/DiscardReason.cs @@ -7,6 +7,7 @@ namespace Sentry.Internal; public static DiscardReason BufferOverflow = new("buffer_overflow"); public static DiscardReason CacheOverflow = new("cache_overflow"); public static DiscardReason EventProcessor = new("event_processor"); + public static DiscardReason Invalid = new("invalid"); public static DiscardReason NetworkError = new("network_error"); public static DiscardReason QueueOverflow = new("queue_overflow"); public static DiscardReason SendError = new("send_error"); diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs b/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs index 2024db884c..1b13c1fa6c 100644 --- a/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs +++ b/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs @@ -163,6 +163,7 @@ public void Emit_Decimal_DoesNotCaptureEnvelope(SentryMetricType type) var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Warning); entry.Message.Should().Be("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); entry.Exception.Should().BeNull(); entry.Args.Should().BeEquivalentTo([typeof(decimal)]); } @@ -183,6 +184,7 @@ public void Emit_Half_DoesNotCaptureEnvelope(SentryMetricType type) var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Warning); entry.Message.Should().Be("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); entry.Exception.Should().BeNull(); entry.Args.Should().BeEquivalentTo([typeof(Half)]); } @@ -203,6 +205,7 @@ public void Emit_Enum_DoesNotCaptureEnvelope(SentryMetricType type) var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Warning); entry.Message.Should().Be("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); entry.Exception.Should().BeNull(); entry.Args.Should().BeEquivalentTo([typeof(StringComparison)]); } @@ -222,6 +225,7 @@ public void Emit_Name_Null_DoesNotCaptureEnvelope(SentryMetricType type, string var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Warning); entry.Message.Should().Be("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}"); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); entry.Exception.Should().BeNull(); entry.Args.Should().BeEquivalentTo([arg0, arg1]); } @@ -241,6 +245,7 @@ public void Emit_Name_Empty_DoesNotCaptureEnvelope(SentryMetricType type, string var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Warning); entry.Message.Should().Be("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}"); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.TraceMetric); entry.Exception.Should().BeNull(); entry.Args.Should().BeEquivalentTo([arg0, arg1]); } diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 33310a0b1c..7eca4850b0 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -167,6 +167,7 @@ public void Log_InvalidFormat_DoesNotCaptureEnvelope() entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.Invalid, DataCategory.LogItem); } [Fact] From 7710900ebaeb0dfb5234cafff0f3c7f5da7dd015 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 23 Sep 2026 14:47:24 +1200 Subject: [PATCH 5/9] fix: log when BeforeSendLog or BeforeSendMetric drops an item Every other deliberate drop by a user callback logs at info level naming the callback; logs and metrics were the exception. Co-Authored-By: Claude Opus 5 --- src/Sentry/Internal/DefaultSentryMetricEmitter.cs | 1 + src/Sentry/Internal/DefaultSentryStructuredLogger.cs | 1 + test/Sentry.Tests/SentryMetricEmitterTests.Values.cs | 9 +++++++++ test/Sentry.Tests/SentryMetricEmitterTests.cs | 5 +++++ test/Sentry.Tests/SentryStructuredLoggerTests.cs | 5 +++++ 5 files changed, 21 insertions(+) diff --git a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs index 04c2e6e4e9..0d24befc7a 100644 --- a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs +++ b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs @@ -89,6 +89,7 @@ private protected override void CaptureMetric(SentryMetric metric) where T if (configuredMetric is null) { _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); + _options.DiagnosticLogger?.LogInfo("Metric dropped by BeforeSendMetric callback."); return; } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 670ff58ea8..72311c14f2 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -104,6 +104,7 @@ protected internal override void CaptureLog(SentryLog log) if (configuredLog is null) { _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + _options.DiagnosticLogger?.LogInfo("Log dropped by BeforeSendLog callback."); return; } diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs b/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs index 497fbffa4f..f6c924297f 100644 --- a/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs +++ b/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs @@ -118,6 +118,7 @@ public void Emit_Unit_MeasurementUnit_Predefined(MeasurementUnit unit, string ex captured.Should().NotBeNull(); captured.Unit.Should().Be(expected); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -135,6 +136,7 @@ public void Emit_Unit_MeasurementUnit_None() captured.Should().NotBeNull(); captured.Unit.Should().Be("none"); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -152,6 +154,7 @@ public void Emit_Unit_MeasurementUnit_Custom() captured.Should().NotBeNull(); captured.Unit.Should().Be("custom_unit"); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -169,6 +172,7 @@ public void Emit_Unit_MeasurementUnit_Empty() captured.Should().NotBeNull(); captured.Unit.Should().BeEmpty(); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -186,6 +190,7 @@ public void Emit_Unit_MeasurementUnit_Null() captured.Should().NotBeNull(); captured.Unit.Should().BeNull(); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -203,6 +208,7 @@ public void Emit_Unit_MeasurementUnit_Default() captured.Should().NotBeNull(); captured.Unit.Should().BeNull(); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -221,6 +227,7 @@ public void Emit_Unit_String_Custom() captured.Should().NotBeNull(); captured.Unit.Should().Be("custom_unit"); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -239,6 +246,7 @@ public void Emit_Unit_String_Empty() captured.Should().NotBeNull(); captured.Unit.Should().BeEmpty(); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [Fact] @@ -257,6 +265,7 @@ public void Emit_Unit_String_Null() captured.Should().NotBeNull(); captured.Unit.Should().BeNull(); + _fixture.DiagnosticLogger.Dequeue().Message.Should().Be("Metric dropped by BeforeSendMetric callback."); } [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "The generic SentryMetric type is internal. Testing via the public abstract base type.")] diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.cs b/test/Sentry.Tests/SentryMetricEmitterTests.cs index 32902a70a8..e676ed0239 100644 --- a/test/Sentry.Tests/SentryMetricEmitterTests.cs +++ b/test/Sentry.Tests/SentryMetricEmitterTests.cs @@ -141,6 +141,11 @@ public void Emit_WhenBeforeSendMetricReturnsNull_DoesNotCaptureEnvelope() _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); invocations.Should().Be(1); _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Info); + entry.Message.Should().Be("Metric dropped by BeforeSendMetric callback."); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEmpty(); } [Fact] diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 7eca4850b0..0ba5544c59 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -152,6 +152,11 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); invocations.Should().Be(1); _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Info); + entry.Message.Should().Be("Log dropped by BeforeSendLog callback."); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEmpty(); } [Fact] From eae8fc3b713ff049548efad53d5f7dde66d3ed6b Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 24 Sep 2026 10:18:46 +1200 Subject: [PATCH 6/9] fix: a throwing log entry filter now drops the entry instead of letting it through Review feedback on #5606. Failing open meant a filter written to exclude entries would, once broken, start sending them. Before this PR the exception reached the app and nothing was sent, so dropping keeps that outcome while removing the crash, and matches the filter rule applied to BeforeSend in #5610. Also aligns the internal screenshot callback names with the public SetBeforeScreenshotCapture, and fixes a doc sample naming a method that no longer exists. Co-Authored-By: Claude Opus 5 --- src/Sentry.Extensions.Logging/SentryLogger.cs | 4 ++-- .../Internal/SentryMauiScreenshotProcessor.cs | 4 ++-- src/Sentry.Maui/SentryMauiOptions.cs | 8 +++---- .../SentryLoggerTests.cs | 23 ++++++++++++++----- .../SentryMauiScreenshotProcessorTests.cs | 6 ++--- .../SentryMauiOptionsTests.cs | 8 +++---- .../SentryMauiScreenshotTests.cs | 4 ++-- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/Sentry.Extensions.Logging/SentryLogger.cs b/src/Sentry.Extensions.Logging/SentryLogger.cs index c4e6a1630e..1c531f179a 100644 --- a/src/Sentry.Extensions.Logging/SentryLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryLogger.cs @@ -192,8 +192,8 @@ private bool IsFiltered( } catch (Exception e) { - _options.LogError(e, "The {0} log filter callback failed. The log entry will not be filtered.", filter.GetType().Name); - return false; + _options.LogError(e, "The {0} log filter callback failed. The log entry will be filtered out.", filter.GetType().Name); + return true; } } diff --git a/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs b/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs index 02f2a04b8f..621b6ab95b 100644 --- a/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs +++ b/src/Sentry.Maui/Internal/SentryMauiScreenshotProcessor.cs @@ -18,7 +18,7 @@ public SentryMauiScreenshotProcessor(SentryMauiOptions options) public SentryEvent? Process(SentryEvent @event, SentryHint hint) { - if (_options.BeforeCaptureInternal is { } beforeCapture) + if (_options.BeforeScreenshotCaptureInternal is { } beforeCapture) { bool shouldCapture; try @@ -27,7 +27,7 @@ public SentryMauiScreenshotProcessor(SentryMauiOptions options) } catch (Exception e) { - _options.LogError(e, "BeforeCaptureScreenshot callback failed."); + _options.LogError(e, "BeforeScreenshotCapture callback failed."); return @event; } diff --git a/src/Sentry.Maui/SentryMauiOptions.cs b/src/Sentry.Maui/SentryMauiOptions.cs index 17038bf747..6a7ecccb8c 100644 --- a/src/Sentry.Maui/SentryMauiOptions.cs +++ b/src/Sentry.Maui/SentryMauiOptions.cs @@ -76,11 +76,11 @@ public SentryMauiOptions() /// public bool AttachScreenshot { get; set; } - private Func? _beforeCapture; + private Func? _beforeScreenshotCapture; /// /// Action performed before attaching a screenshot /// - internal Func? BeforeCaptureInternal => _beforeCapture; + internal Func? BeforeScreenshotCaptureInternal => _beforeScreenshotCapture; /// /// Configures a callback function to be invoked before taking a screenshot @@ -90,7 +90,7 @@ public SentryMauiOptions() /// /// /// - ///options.SetBeforeCapture((@event, hint) => + ///options.SetBeforeScreenshotCapture((@event, hint) => ///{ /// // Return true to capture or false to prevent the capture /// return true; @@ -99,6 +99,6 @@ public SentryMauiOptions() /// Callback to be executed before taking a screenshot public void SetBeforeScreenshotCapture(Func beforeCapture) { - _beforeCapture = beforeCapture; + _beforeScreenshotCapture = beforeCapture; } } diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs index 09aaeea5ff..18ea187e00 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs @@ -215,7 +215,7 @@ public void LogCritical_NotMatchingFilter_CapturesEvent() } [Fact] - public void LogCritical_FilterThrows_CapturesEventAndLogsError() + public void LogCritical_FilterThrows_DoesNotCaptureEventAndLogsError() { var exception = new InvalidOperationException("filter failed"); _fixture.Options.AddLogEntryFilter((_, _, _, _) => throw exception); @@ -225,24 +225,35 @@ public void LogCritical_FilterThrows_CapturesEventAndLogsError() sut.LogCritical("message"); - _ = _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + _ = _fixture.Hub.DidNotReceive().CaptureEvent(Arg.Any()); _fixture.Options.ReceivedLogError(exception, - "The {0} log filter callback failed. The log entry will not be filtered.", + "The {0} log filter callback failed. The log entry will be filtered out.", nameof(DelegateLogEntryFilter)); } [Fact] - public void LogCritical_FilterThrows_OtherFiltersStillApply() + public void LogCritical_FilterThrows_DoesNotAddBreadcrumb() { _fixture.Options.AddLogEntryFilter((_, _, _, _) => throw new InvalidOperationException("filter failed")); - _fixture.Options.AddLogEntryFilter((_, _, _, _) => true); _fixture.Options.AddDiagnosticLoggerSubstitute(); var sut = _fixture.GetSut(); sut.LogCritical("message"); - _ = _fixture.Hub.DidNotReceive().CaptureEvent(Arg.Any()); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + } + + [Fact] + public void LogCritical_FilterThrows_DoesNotReachTheCaller() + { + _fixture.Options.AddLogEntryFilter((_, _, _, _) => throw new InvalidOperationException("filter failed")); + + var sut = _fixture.GetSut(); + + var log = () => sut.LogCritical("message"); + + log.Should().NotThrow(); } [Fact] diff --git a/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs b/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs index 56e1fc1792..438360a86a 100644 --- a/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs +++ b/test/Sentry.Maui.Tests/Internal/SentryMauiScreenshotProcessorTests.cs @@ -5,7 +5,7 @@ namespace Sentry.Maui.Tests.Internal; public class SentryMauiScreenshotProcessorTests { [Fact] - public void Process_BeforeCaptureScreenshotThrows_KeepsEventAndSkipsScreenshot() + public void Process_BeforeScreenshotCaptureThrows_KeepsEventAndSkipsScreenshot() { // Arrange var exception = new InvalidOperationException("callback failed"); @@ -30,11 +30,11 @@ public void Process_BeforeCaptureScreenshotThrows_KeepsEventAndSkipsScreenshot() logger.Entries.Should().ContainSingle(entry => entry.Level == SentryLevel.Error && entry.Exception == exception && - entry.Message == "BeforeCaptureScreenshot callback failed."); + entry.Message == "BeforeScreenshotCapture callback failed."); } [Fact] - public void Process_BeforeCaptureScreenshotReturnsTrue_AddsScreenshot() + public void Process_BeforeScreenshotCaptureReturnsTrue_AddsScreenshot() { // Arrange var options = new SentryMauiOptions(); diff --git a/test/Sentry.Maui.Tests/SentryMauiOptionsTests.cs b/test/Sentry.Maui.Tests/SentryMauiOptionsTests.cs index 1d2a9ae9ed..b3fee91394 100644 --- a/test/Sentry.Maui.Tests/SentryMauiOptionsTests.cs +++ b/test/Sentry.Maui.Tests/SentryMauiOptionsTests.cs @@ -92,7 +92,7 @@ public void HandlerStrategy_Set() #endif [Fact] - public void BeforeCaptureScreenshot_Set() + public void BeforeScreenshotCapture_Set() { // Arrange var options = GetSut(); @@ -105,17 +105,17 @@ public void BeforeCaptureScreenshot_Set() }); // Assert - Assert.NotNull(options.BeforeCaptureInternal); + Assert.NotNull(options.BeforeScreenshotCaptureInternal); } [Fact] - public void BeforeCaptureScreenshot_NotSet() + public void BeforeScreenshotCapture_NotSet() { // Arrange var options = GetSut(); options.AttachScreenshot = true; // Assert - Assert.Null(options.BeforeCaptureInternal); + Assert.Null(options.BeforeScreenshotCaptureInternal); } } diff --git a/test/Sentry.Maui.Tests/SentryMauiScreenshotTests.cs b/test/Sentry.Maui.Tests/SentryMauiScreenshotTests.cs index 89f9ad00af..1c9bf644fc 100644 --- a/test/Sentry.Maui.Tests/SentryMauiScreenshotTests.cs +++ b/test/Sentry.Maui.Tests/SentryMauiScreenshotTests.cs @@ -109,7 +109,7 @@ public async Task CaptureException_RemoveScreenshot_NotContainsScreenshotAttachm } [SkippableFact] - public async Task CaptureException_BeforeCaptureScreenshot_DisableCaptureAsync() + public async Task CaptureException_BeforeScreenshotCapture_DisableCaptureAsync() { #if __IOS__ Skip.If(true, "Flaky on iOS"); @@ -141,7 +141,7 @@ public async Task CaptureException_BeforeCaptureScreenshot_DisableCaptureAsync() // various static members like ActivityStateManager.Default: // https://github.com/dotnet/maui/blob/3c7b65264d2f341a48db32263a271fd8718cfd23/src/Essentials/src/Screenshot/Screenshot.android.cs#L28 [SkippableFact] - public async Task CaptureException_BeforeCaptureScreenshot_DefaultAsync() + public async Task CaptureException_BeforeScreenshotCapture_DefaultAsync() { #if __IOS__ Skip.If(true, "Flaky on iOS"); From 9dac9c2088f261eace62356899b79307b0ab14e6 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 24 Sep 2026 10:45:41 +1200 Subject: [PATCH 7/9] ref: evaluate log entry filters once per log call instead of twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShouldCaptureEvent and ShouldAddBreadcrumb each ran the whole filter chain, so a user filter was invoked twice for every entry that cleared both thresholds — and, since the previous commit, logged its failure twice. The two predicates now test only their level threshold, and the shared guard runs once in Log. Co-Authored-By: Claude Opus 5 --- src/Sentry.Extensions.Logging/SentryLogger.cs | 29 +++++++------------ .../SentryLoggerTests.cs | 19 ++++++++++++ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/Sentry.Extensions.Logging/SentryLogger.cs b/src/Sentry.Extensions.Logging/SentryLogger.cs index 1c531f179a..9a25cfc0b5 100644 --- a/src/Sentry.Extensions.Logging/SentryLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryLogger.cs @@ -52,7 +52,12 @@ public void Log( var message = formatter?.Invoke(state, exception); - if (ShouldCaptureEvent(logLevel, eventId, exception)) + if (IsFromSentry() || IsEfExceptionMessage(eventId) || IsFiltered(logLevel, eventId, exception)) + { + return; + } + + if (ShouldCaptureEvent(logLevel)) { var @event = CreateEvent(logLevel, eventId, state, exception, message, CategoryName); @@ -65,7 +70,7 @@ public void Log( } } - if (ShouldAddBreadcrumb(logLevel, eventId, exception)) + if (ShouldAddBreadcrumb(logLevel)) { var data = eventId.ToDictionaryOrNull(); @@ -154,25 +159,13 @@ internal static SentryEvent CreateEvent( return @event; } - private bool ShouldCaptureEvent( - LogLevel logLevel, - EventId eventId, - Exception? exception) + private bool ShouldCaptureEvent(LogLevel logLevel) => _options.MinimumEventLevel != LogLevel.None - && logLevel >= _options.MinimumEventLevel - && !IsFromSentry() - && !IsEfExceptionMessage(eventId) - && !IsFiltered(logLevel, eventId, exception); + && logLevel >= _options.MinimumEventLevel; - private bool ShouldAddBreadcrumb( - LogLevel logLevel, - EventId eventId, - Exception? exception) + private bool ShouldAddBreadcrumb(LogLevel logLevel) => _options.MinimumBreadcrumbLevel != LogLevel.None - && logLevel >= _options.MinimumBreadcrumbLevel - && !IsFromSentry() - && !IsEfExceptionMessage(eventId) - && !IsFiltered(logLevel, eventId, exception); + && logLevel >= _options.MinimumBreadcrumbLevel; private bool IsFiltered( LogLevel logLevel, diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs index 18ea187e00..58fd48ab14 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggerTests.cs @@ -214,6 +214,25 @@ public void LogCritical_NotMatchingFilter_CapturesEvent() .CaptureEvent(Arg.Any()); } + [Fact] + public void LogCritical_EventAndBreadcrumbLevelsBothMet_EvaluatesFilterOnce() + { + var invocations = 0; + _fixture.Options.MinimumEventLevel = LogLevel.Critical; + _fixture.Options.MinimumBreadcrumbLevel = LogLevel.Debug; + _fixture.Options.AddLogEntryFilter((_, _, _, _) => + { + invocations++; + return false; + }); + + var sut = _fixture.GetSut(); + + sut.LogCritical("message"); + + invocations.Should().Be(1); + } + [Fact] public void LogCritical_FilterThrows_DoesNotCaptureEventAndLogsError() { From 8992aa908b7225c41ed5b8022f0c3763864aa9c5 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Sep 2026 09:12:24 +1200 Subject: [PATCH 8/9] fix: report callback failures with the callback_error discard reason Follows the hooks spec revision in getsentry/sentry-docs#19189 (b53c3009): a throwing callback must be distinguishable from an explicit drop, so every item a failure drops is recorded as callback_error with the item's category. Null returns keep their existing reasons. Co-Authored-By: Claude Opus 5 --- src/Sentry/Internal/DefaultSentryMetricEmitter.cs | 2 +- src/Sentry/Internal/DefaultSentryStructuredLogger.cs | 4 ++-- src/Sentry/Internal/DiscardReason.cs | 1 + src/Sentry/Internal/SentryEventHelper.cs | 4 ++-- src/Sentry/SentryClient.cs | 4 ++-- test/Sentry.Tests/SentryClientTests.cs | 6 +++--- test/Sentry.Tests/SentryMetricEmitterTests.cs | 2 +- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 4 ++-- 8 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs index 0d24befc7a..2b1f423d3f 100644 --- a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs +++ b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs @@ -80,7 +80,7 @@ private protected override void CaptureMetric(SentryMetric metric) where T } catch (Exception e) { - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.TraceMetric); _options.DiagnosticLogger?.LogError(e, "The BeforeSendMetric callback threw an exception. The Metric will be dropped."); return; } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 72311c14f2..4bcf0862a9 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -71,7 +71,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template } catch (Exception e) { - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.LogItem); _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); return; } @@ -95,7 +95,7 @@ protected internal override void CaptureLog(SentryLog log) } catch (Exception e) { - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.LogItem); _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); return; } diff --git a/src/Sentry/Internal/DiscardReason.cs b/src/Sentry/Internal/DiscardReason.cs index 7ed23144ae..ac7bd9e737 100644 --- a/src/Sentry/Internal/DiscardReason.cs +++ b/src/Sentry/Internal/DiscardReason.cs @@ -4,6 +4,7 @@ namespace Sentry.Internal; { // See https://develop.sentry.dev/sdk/client-reports/ for list public static DiscardReason BeforeSend = new("before_send"); + public static DiscardReason CallbackError = new("callback_error"); public static DiscardReason BufferOverflow = new("buffer_overflow"); public static DiscardReason CacheOverflow = new("cache_overflow"); public static DiscardReason EventProcessor = new("event_processor"); diff --git a/src/Sentry/Internal/SentryEventHelper.cs b/src/Sentry/Internal/SentryEventHelper.cs index 8c0dad38aa..561ba6aea9 100644 --- a/src/Sentry/Internal/SentryEventHelper.cs +++ b/src/Sentry/Internal/SentryEventHelper.cs @@ -23,7 +23,7 @@ internal static class SentryEventHelper } catch (Exception e) { - options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, dataCategory); + options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, dataCategory); options.LogError(e, "Event processor {0} threw an exception. The event will be dropped.", processor.GetType().Name); return null; } @@ -104,7 +104,7 @@ internal static class SentryEventHelper } catch (Exception e) { - options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Feedback); + options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Feedback); options.LogError(e, "The BeforeSendFeedback callback threw an exception. The feedback will be dropped."); return null; } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 64e82e037f..75ee38887f 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -218,8 +218,8 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent } catch (Exception e) { - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Transaction); - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Span, spanCount); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Transaction); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Span, spanCount); _options.LogError(e, "Transaction processor {0} threw an exception. The transaction will be dropped.", processor.GetType().Name); return; } diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 2eff11188f..32d3427031 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -401,7 +401,7 @@ public void CaptureEvent_EventProcessorThrows_DropsEventAndRecordsDiscard() id.Should().Be(SentryId.Empty); _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); _fixture.ClientReportRecorder.Received(1) - .RecordDiscardedEvent(DiscardReason.EventProcessor, DataCategory.Error); + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); } [Fact] @@ -1143,7 +1143,7 @@ public void CaptureFeedback_BeforeSendFeedbackThrows_FeedbackDropped() result.Should().Be(CaptureFeedbackResult.DroppedByBeforeSendFeedback); id.Should().Be(SentryId.Empty); _ = sut.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); - _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Feedback); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Feedback); } [Fact] @@ -1731,7 +1731,7 @@ public void CaptureTransaction_TransactionProcessorThrows_DropsTransactionAndRec // Assert _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); - var reason = DiscardReason.EventProcessor; + var reason = DiscardReason.CallbackError; _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Transaction); var expectedDroppedSpanCount = transaction.Spans.Count + 1; _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Span, expectedDroppedSpanCount); diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.cs b/test/Sentry.Tests/SentryMetricEmitterTests.cs index e676ed0239..391a2ff22e 100644 --- a/test/Sentry.Tests/SentryMetricEmitterTests.cs +++ b/test/Sentry.Tests/SentryMetricEmitterTests.cs @@ -162,7 +162,7 @@ public void Emit_InvalidBeforeSendMetric_DoesNotCaptureEnvelope() entry.Message.Should().Be("The BeforeSendMetric callback threw an exception. The Metric will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); - _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.TraceMetric); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.TraceMetric); } [Fact] diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 0ba5544c59..b140893203 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -188,7 +188,7 @@ public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() entry.Message.Should().Be("The configureLog callback threw an exception. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); - _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.LogItem); } [Fact] @@ -205,7 +205,7 @@ public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() entry.Message.Should().Be("The BeforeSendLog callback threw an exception. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); - _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.LogItem); + _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.LogItem); } [Fact] From f6ee5c567356cbddca365399d82e5fc198a38772 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Sep 2026 23:07:13 +1200 Subject: [PATCH 9/9] chore: rebuild the pull request merge ref GitHub stopped rebuilding refs/pull/5607/merge, so no pull_request workflow could be created and the PR reported a conflict that git shows does not exist. A new head commit forces the ref to be recomputed. Co-Authored-By: Claude Opus 5