From 5afe8bd940cbc02f90a8d282f5beb8abdcf53eb7 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Sep 2026 12:31:35 +1200 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 450f1db6760c255141bbac93ca322b3819c09ba6 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Sep 2026 16:35:28 +1200 Subject: [PATCH 04/13] fix: an exception in BeforeSend or BeforeSendTransaction now drops the item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work item 3 of #5535. Both callbacks previously demystified the exception, stapled its message and stack trace onto the item as a breadcrumb, and sent the item anyway. The spec says a callback failure MUST NOT be attached to the item, and that filters drop — a callback that threw part-way may not have applied the redaction the user wrote it to apply, so the partially-scrubbed item plus the exception detail were both reaching Sentry. Co-Authored-By: Claude Opus 5 --- src/Sentry/Internal/SentryEventHelper.cs | 26 +-------- src/Sentry/SentryClient.cs | 32 +--------- ...ErrorToEventBreadcrumb.Net4_8.verified.txt | 14 ----- ...Throws_ErrorToEventBreadcrumb.verified.txt | 14 ----- ...Throws_ErrorToEventBreadcrumb.verified.txt | 14 ----- test/Sentry.Tests/SentryClientTests.cs | 58 +++++++++++++++++++ test/Sentry.Tests/SentryClientTests.verify.cs | 35 ----------- 7 files changed, 64 insertions(+), 129 deletions(-) delete mode 100644 test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.Net4_8.verified.txt delete mode 100644 test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.verified.txt delete mode 100644 test/Sentry.Tests/SentryClientTests.CaptureTransaction_BeforeSendTransactionThrows_ErrorToEventBreadcrumb.verified.txt delete mode 100644 test/Sentry.Tests/SentryClientTests.verify.cs diff --git a/src/Sentry/Internal/SentryEventHelper.cs b/src/Sentry/Internal/SentryEventHelper.cs index 8c0dad38aa..9988e37669 100644 --- a/src/Sentry/Internal/SentryEventHelper.cs +++ b/src/Sentry/Internal/SentryEventHelper.cs @@ -38,9 +38,6 @@ internal static class SentryEventHelper return processedEvent; } -#if NET6_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026: RequiresUnreferencedCode", Justification = AotHelper.AvoidAtRuntime)] -#endif public static SentryEvent? DoBeforeSend(SentryEvent? @event, SentryHint hint, SentryOptions options) { if (@event is null || options.BeforeSendInternal is null) @@ -60,26 +57,9 @@ internal static class SentryEventHelper } catch (Exception e) { - if (!AotHelper.IsTrimmed) - { - // Attempt to demystify exceptions before adding them as breadcrumbs. - e.Demystify(); - } - - options.LogError(e, "The BeforeSend callback threw an exception. It will be added as breadcrumb and continue."); - var data = new Dictionary - { - {"message", e.Message} - }; - if (e.StackTrace is not null) - { - data.Add("stackTrace", e.StackTrace); - } - @event?.AddBreadcrumb( - "BeforeSend callback failed.", - category: "SentryClient", - data: data, - level: BreadcrumbLevel.Error); + options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Error); + options.LogError(e, "The BeforeSend callback threw an exception. The event will be dropped."); + return null; } return @event; diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 64e82e037f..b262fe7e13 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -253,9 +253,6 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent CaptureEnvelope(Envelope.FromTransaction(processedTransaction, _options.DiagnosticLogger, attachments)); } -#if NET6_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026: RequiresUnreferencedCode", Justification = AotHelper.AvoidAtRuntime)] -#endif private SentryTransaction? BeforeSendTransaction(SentryTransaction transaction, SentryHint hint) { if (_options.BeforeSendTransactionInternal is null) @@ -267,36 +264,13 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent try { - return _options.BeforeSendTransactionInternal?.Invoke(transaction, hint); + return _options.BeforeSendTransactionInternal.Invoke(transaction, hint); } catch (Exception e) { - if (!AotHelper.IsTrimmed) - { - // Attempt to demystify exceptions before adding them as breadcrumbs. - e.Demystify(); - } - - _options.LogError(e, "The BeforeSendTransaction callback threw an exception. It will be added as breadcrumb and continue."); - - var data = new Dictionary - { - {"message", e.Message} - }; - - if (e.StackTrace is not null) - { - data.Add("stackTrace", e.StackTrace); - } - - transaction.AddBreadcrumb( - message: "BeforeSendTransaction callback failed.", - category: "SentryClient", - data: data, - level: BreadcrumbLevel.Error); + _options.LogError(e, "The BeforeSendTransaction callback threw an exception. The transaction will be dropped."); + return null; } - - return transaction; } /// diff --git a/test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.Net4_8.verified.txt b/test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.Net4_8.verified.txt deleted file mode 100644 index 6ce33e1e01..0000000000 --- a/test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.Net4_8.verified.txt +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - Timestamp: DateTimeOffset_1, - Message: BeforeSend callback failed., - Data: { - message: Exception message!, - stackTrace: -at Task Sentry.Tests.SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb() -at SentryEvent Sentry.SentryClient.BeforeSend(...) - }, - Category: SentryClient, - Level: error - } -] \ No newline at end of file diff --git a/test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.verified.txt b/test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.verified.txt deleted file mode 100644 index 97e165f68b..0000000000 --- a/test/Sentry.Tests/SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb.verified.txt +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - Timestamp: DateTimeOffset_1, - Message: BeforeSend callback failed., - Data: { - message: Exception message!, - stackTrace: -at Task Sentry.Tests.SentryClientTests.CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb() -at SentryEvent Sentry.Internal.SentryEventHelper.DoBeforeSend(...) - }, - Category: SentryClient, - Level: error - } -] \ No newline at end of file diff --git a/test/Sentry.Tests/SentryClientTests.CaptureTransaction_BeforeSendTransactionThrows_ErrorToEventBreadcrumb.verified.txt b/test/Sentry.Tests/SentryClientTests.CaptureTransaction_BeforeSendTransactionThrows_ErrorToEventBreadcrumb.verified.txt deleted file mode 100644 index c6f3a16345..0000000000 --- a/test/Sentry.Tests/SentryClientTests.CaptureTransaction_BeforeSendTransactionThrows_ErrorToEventBreadcrumb.verified.txt +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - Timestamp: DateTimeOffset_1, - Message: BeforeSendTransaction callback failed., - Data: { - message: Exception message!, - stackTrace: -at Task Sentry.Tests.SentryClientTests.CaptureTransaction_BeforeSendTransactionThrows_ErrorToEventBreadcrumb() -at SentryTransaction Sentry.SentryClient.BeforeSendTransaction(...) - }, - Category: SentryClient, - Level: error - } -] \ No newline at end of file diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 2eff11188f..441841d62a 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -372,6 +372,36 @@ public void CaptureEvent_BeforeEvent_RejectEvent_RecordsDiscard() .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Error); } + [Fact] + public void CaptureEvent_BeforeSendThrows_DropsEventAndRecordsDiscard() + { + var exception = new InvalidOperationException("callback failed"); + _fixture.SentryOptions.SetBeforeSend((_, _) => throw exception); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + var @event = new SentryEvent(); + + var sut = _fixture.GetSut(); + var id = sut.CaptureEvent(@event); + + id.Should().Be(SentryId.Empty); + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Error); + _fixture.SentryOptions.ReceivedLogError(exception, "The BeforeSend callback threw an exception. The event will be dropped."); + } + + [Fact] + public void CaptureEvent_BeforeSendThrows_DoesNotAttachFailureToEvent() + { + _fixture.SentryOptions.SetBeforeSend((_, _) => throw new InvalidOperationException("callback failed")); + var @event = new SentryEvent(); + + var sut = _fixture.GetSut(); + _ = sut.CaptureEvent(@event); + + @event.Breadcrumbs.Should().BeEmpty(); + } + [Fact] public void CaptureEvent_EventProcessor_RejectEvent_RecordsDiscard() { @@ -1737,6 +1767,34 @@ public void CaptureTransaction_TransactionProcessorThrows_DropsTransactionAndRec _fixture.ClientReportRecorder.Received(1).RecordDiscardedEvent(reason, DataCategory.Span, expectedDroppedSpanCount); } + [Fact] + public void CaptureTransaction_BeforeSendTransactionThrows_DropsTransactionAndRecordsDiscard() + { + // Arrange + var exception = new InvalidOperationException("callback failed"); + _fixture.SentryOptions.SetBeforeSendTransaction((_, _) => throw exception); + _fixture.SentryOptions.AddDiagnosticLoggerSubstitute(); + + var hub = Substitute.For(); + var tracer = new TransactionTracer(hub, "test name", "test operation"); + tracer.StartChild("span1"); + tracer.StartChild("span2"); + tracer.EndTimestamp = DateTimeOffset.Now; // finished + var transaction = new SentryTransaction(tracer) { IsSampled = true }; + + // Act + _fixture.GetSut().CaptureTransaction(transaction); + + // Assert + _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Transaction); + _fixture.ClientReportRecorder.Received(1) + .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Span, transaction.Spans.Count + 1); + _fixture.SentryOptions.ReceivedLogError(exception, "The BeforeSendTransaction callback threw an exception. The transaction will be dropped."); + transaction.Breadcrumbs.Should().BeEmpty(); + } + [Fact] public void CaptureTransaction_BeforeSendTransaction_GetsHint() { diff --git a/test/Sentry.Tests/SentryClientTests.verify.cs b/test/Sentry.Tests/SentryClientTests.verify.cs deleted file mode 100644 index 329f8065d0..0000000000 --- a/test/Sentry.Tests/SentryClientTests.verify.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Sentry.Tests; - -public partial class SentryClientTests -{ - [Fact] - public Task CaptureEvent_BeforeEventThrows_ErrorToEventBreadcrumb() - { - var error = new Exception("Exception message!"); - _fixture.SentryOptions.SetBeforeSend((_, _) => throw error); - - var @event = new SentryEvent(); - - var sut = _fixture.GetSut(); - _ = sut.CaptureEvent(@event); - - return Verify(@event.Breadcrumbs); - } - - [Fact] - public Task CaptureTransaction_BeforeSendTransactionThrows_ErrorToEventBreadcrumb() - { - var error = new Exception("Exception message!"); - _fixture.SentryOptions.SetBeforeSendTransaction((_, _) => throw error); - - var transaction = new SentryTransaction("name", "operation") - { - IsSampled = true - }; - - var sut = _fixture.GetSut(); - sut.CaptureTransaction(transaction); - - return Verify(transaction.Breadcrumbs); - } -} From 6de2c4026cf279dcaa0d48b060999bb786b70c8f Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 23 Sep 2026 13:28:40 +1200 Subject: [PATCH 05/13] 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 06/13] 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 07/13] 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 08/13] 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 ab581629fa676ab80a3ff4172ae0d914435b83e0 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 24 Sep 2026 11:05:06 +1200 Subject: [PATCH 09/13] fix: a throwing BeforeSend on the Android bridge no longer sends the unscrubbed native event The JNI bridge wrapped its whole body in a catch that returns the original Java event, so a scrubber that threw part-way sent the completely unredacted native event. That catch was added for serialization safety, so the guard goes around the user callback inside BeforeSendWrapper instead: it returns null, which the Java SDK turns into a drop and accounts for in its own client report, and Execute's catch keeps failing open only for serialization. Also covers the Cocoa native-crash path, where a throwing callback now drops the event and previously had no test at all. Co-Authored-By: Claude Opus 5 --- src/Sentry/Platforms/Android/SentrySdk.cs | 18 +++++++-- .../Platforms/Android/SentrySdkTests.cs | 23 ++++++++++++ .../Platforms/iOS/SentrySdkTests.cs | 37 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs diff --git a/src/Sentry/Platforms/Android/SentrySdk.cs b/src/Sentry/Platforms/Android/SentrySdk.cs index ab07387924..5e2fc52ce5 100644 --- a/src/Sentry/Platforms/Android/SentrySdk.cs +++ b/src/Sentry/Platforms/Android/SentrySdk.cs @@ -238,9 +238,21 @@ private static void InitSentryAndroidSdk(SentryOptions options) } // Call the user defined BeforeSend callback, if it's defined - otherwise return the event as-is - return (options.Native.EnableBeforeSend && options.BeforeSendInternal is { } beforeSend) - ? beforeSend(evt, hint) - : evt; + if (!options.Native.EnableBeforeSend || options.BeforeSendInternal is not { } beforeSend) + { + return evt; + } + + try + { + return beforeSend(evt, hint); + } + catch (Exception e) + { + // Dropping the event is left to the Java SDK, which also records the client report for it. + options.LogError(e, "Android BeforeSend callback failed."); + return null; + } }; } diff --git a/test/Sentry.Tests/Platforms/Android/SentrySdkTests.cs b/test/Sentry.Tests/Platforms/Android/SentrySdkTests.cs index 96bde8e62b..6dfd12dc9b 100644 --- a/test/Sentry.Tests/Platforms/Android/SentrySdkTests.cs +++ b/test/Sentry.Tests/Platforms/Android/SentrySdkTests.cs @@ -112,5 +112,28 @@ public void BeforeSendWrapper_NativeBeforeSendDisabled_ReturnsEvent() beforeSend.DidNotReceive().Invoke(Arg.Any(), Arg.Any()); result.Should().Be(evt); } + + [Fact] + public void BeforeSendWrapper_BeforeSendThrows_ReturnsNullAndLogsError() + { + // Arrange + var exception = new InvalidOperationException("callback failed"); + var logger = new InMemoryDiagnosticLogger(); + var options = new SentryOptions { Debug = true, DiagnosticLogger = logger }; + options.Native.EnableBeforeSend = true; + options.SetBeforeSend((SentryEvent _, SentryHint _) => throw exception); + var evt = new SentryEvent(); + var hint = new SentryHint(); + + // Act + var result = SentrySdk.BeforeSendWrapper(options).Invoke(evt, hint); + + // Assert + result.Should().BeNull(); + logger.Entries.Should().ContainSingle(entry => + entry.Level == SentryLevel.Error && + entry.Exception == exception && + entry.Message == "Android BeforeSend callback failed."); + } } #endif diff --git a/test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs b/test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs new file mode 100644 index 0000000000..70c6c43da9 --- /dev/null +++ b/test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using NSubstitute; +using Xunit; + +namespace Sentry.Tests.Platforms.iOS; + +public class SentrySdkTests +{ + [Fact] + public void ProcessOnBeforeSend_BeforeSendThrows_DropsNativeEventAndLogsError() + { + var exception = new InvalidOperationException("callback failed"); + var logger = new InMemoryDiagnosticLogger(); + var options = new SentryOptions { Debug = true, DiagnosticLogger = logger }; + options.SetBeforeSend((SentryEvent _, SentryHint _) => throw exception); + + var result = SentrySdk.ProcessOnBeforeSend(options, new CocoaSdk.SentryObjCEvent(), Substitute.For()); + + result.Should().BeNull(); + logger.Entries.Should().ContainSingle(entry => + entry.Level == SentryLevel.Error && + entry.Exception == exception && + entry.Message == "The BeforeSend callback threw an exception. The event will be dropped."); + } + + [Fact] + public void ProcessOnBeforeSend_BeforeSendReturnsEvent_KeepsNativeEvent() + { + var options = new SentryOptions(); + options.SetBeforeSend((SentryEvent @event, SentryHint _) => @event); + var native = new CocoaSdk.SentryObjCEvent(); + + var result = SentrySdk.ProcessOnBeforeSend(options, native, Substitute.For()); + + result.Should().BeSameAs(native); + } +} From 9d42b4b4c0e5d1200a83ad9bfde5050a511f6495 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 24 Sep 2026 12:36:42 +1200 Subject: [PATCH 10/13] test: move the Cocoa BeforeSend tests in with the existing ProcessOnBeforeSend family Sentry.Tests.SentrySdkTests already has six ProcessOnBeforeSend tests inside an #if __IOS__ block. A second SentrySdkTests class under Platforms/iOS was a confusing duplicate name in a parallel namespace. Co-Authored-By: Claude Opus 5 --- .../Platforms/iOS/SentrySdkTests.cs | 37 ------------------ test/Sentry.Tests/SentrySdkTests.cs | 38 +++++++++++++++++++ 2 files changed, 38 insertions(+), 37 deletions(-) delete mode 100644 test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs diff --git a/test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs b/test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs deleted file mode 100644 index 70c6c43da9..0000000000 --- a/test/Sentry.Tests/Platforms/iOS/SentrySdkTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using FluentAssertions; -using NSubstitute; -using Xunit; - -namespace Sentry.Tests.Platforms.iOS; - -public class SentrySdkTests -{ - [Fact] - public void ProcessOnBeforeSend_BeforeSendThrows_DropsNativeEventAndLogsError() - { - var exception = new InvalidOperationException("callback failed"); - var logger = new InMemoryDiagnosticLogger(); - var options = new SentryOptions { Debug = true, DiagnosticLogger = logger }; - options.SetBeforeSend((SentryEvent _, SentryHint _) => throw exception); - - var result = SentrySdk.ProcessOnBeforeSend(options, new CocoaSdk.SentryObjCEvent(), Substitute.For()); - - result.Should().BeNull(); - logger.Entries.Should().ContainSingle(entry => - entry.Level == SentryLevel.Error && - entry.Exception == exception && - entry.Message == "The BeforeSend callback threw an exception. The event will be dropped."); - } - - [Fact] - public void ProcessOnBeforeSend_BeforeSendReturnsEvent_KeepsNativeEvent() - { - var options = new SentryOptions(); - options.SetBeforeSend((SentryEvent @event, SentryHint _) => @event); - var native = new CocoaSdk.SentryObjCEvent(); - - var result = SentrySdk.ProcessOnBeforeSend(options, native, Substitute.For()); - - result.Should().BeSameAs(native); - } -} diff --git a/test/Sentry.Tests/SentrySdkTests.cs b/test/Sentry.Tests/SentrySdkTests.cs index c0dde5651f..402a644492 100644 --- a/test/Sentry.Tests/SentrySdkTests.cs +++ b/test/Sentry.Tests/SentrySdkTests.cs @@ -1462,6 +1462,44 @@ public void ProcessOnBeforeSend_EventProcessorsInvoked() // Assert eventProcessor.Invoked.Should().BeTrue(); } + + [Fact] + public void ProcessOnBeforeSend_BeforeSendThrows_DropsNativeEvent() + { + // Arrange + var exception = new InvalidOperationException("callback failed"); + var logger = new InMemoryDiagnosticLogger(); + var options = new SentryOptions { Debug = true, DiagnosticLogger = logger }; + options.SetBeforeSend((SentryEvent _, SentryHint _) => throw exception); + + var native = new Sentry.CocoaSdk.SentryObjCEvent(); + + // Act + var result = SentrySdk.ProcessOnBeforeSend(options, native, Substitute.For()); + + // Assert + result.Should().BeNull(); + logger.Entries.Should().ContainSingle(entry => + entry.Level == SentryLevel.Error && + entry.Exception == exception && + entry.Message == "The BeforeSend callback threw an exception. The event will be dropped."); + } + + [Fact] + public void ProcessOnBeforeSend_BeforeSendReturnsEvent_KeepsNativeEvent() + { + // Arrange + var options = new SentryOptions(); + options.SetBeforeSend((SentryEvent @event, SentryHint _) => @event); + + var native = new Sentry.CocoaSdk.SentryObjCEvent(); + + // Act + var result = SentrySdk.ProcessOnBeforeSend(options, native, Substitute.For()); + + // Assert + result.Should().BeSameAs(native); + } #endif public void Dispose() From 8992aa908b7225c41ed5b8022f0c3763864aa9c5 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Sep 2026 09:12:24 +1200 Subject: [PATCH 11/13] 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 3f11a6cb656daee8157db83d4fda1fb64faa206a Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Sep 2026 09:17:59 +1200 Subject: [PATCH 12/13] fix: report BeforeSend and BeforeSendTransaction failures as callback_error Follows the hooks spec revision in getsentry/sentry-docs#19189 (b53c3009). BeforeSendTransaction now records its own discard so a throw is distinguishable from a null return, which the shared caller could not tell apart. Co-Authored-By: Claude Opus 5 --- src/Sentry/Internal/SentryEventHelper.cs | 2 +- src/Sentry/SentryClient.cs | 19 +++++++++++++------ test/Sentry.Tests/SentryClientTests.cs | 6 +++--- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Sentry/Internal/SentryEventHelper.cs b/src/Sentry/Internal/SentryEventHelper.cs index 90ee5af134..cc12cbb40c 100644 --- a/src/Sentry/Internal/SentryEventHelper.cs +++ b/src/Sentry/Internal/SentryEventHelper.cs @@ -57,7 +57,7 @@ internal static class SentryEventHelper } catch (Exception e) { - options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Error); + options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); options.LogError(e, "The BeforeSend callback threw an exception. The event will be dropped."); return null; } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 8e3d944db7..cb7a395fcb 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -233,12 +233,9 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent } } - processedTransaction = BeforeSendTransaction(processedTransaction, hint); + processedTransaction = BeforeSendTransaction(processedTransaction, hint, spanCount); if (processedTransaction is null) // Rejected transaction { - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Transaction); - _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Span, spanCount); - _options.LogInfo("Transaction dropped by BeforeSendTransaction callback."); return; } @@ -253,7 +250,7 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent CaptureEnvelope(Envelope.FromTransaction(processedTransaction, _options.DiagnosticLogger, attachments)); } - private SentryTransaction? BeforeSendTransaction(SentryTransaction transaction, SentryHint hint) + private SentryTransaction? BeforeSendTransaction(SentryTransaction transaction, SentryHint hint, int spanCount) { if (_options.BeforeSendTransactionInternal is null) { @@ -264,10 +261,20 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent try { - return _options.BeforeSendTransactionInternal.Invoke(transaction, hint); + var processedTransaction = _options.BeforeSendTransactionInternal.Invoke(transaction, hint); + if (processedTransaction is null) // Rejected transaction + { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Transaction); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Span, spanCount); + _options.LogInfo("Transaction dropped by BeforeSendTransaction callback."); + } + + return processedTransaction; } catch (Exception e) { + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Transaction); + _options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Span, spanCount); _options.LogError(e, "The BeforeSendTransaction callback threw an exception. The transaction will be dropped."); return null; } diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 125b55618b..ba5c31f99a 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -386,7 +386,7 @@ public void CaptureEvent_BeforeSendThrows_DropsEventAndRecordsDiscard() id.Should().Be(SentryId.Empty); _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); _fixture.ClientReportRecorder.Received(1) - .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Error); + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); _fixture.SentryOptions.ReceivedLogError(exception, "The BeforeSend callback threw an exception. The event will be dropped."); } @@ -1788,9 +1788,9 @@ public void CaptureTransaction_BeforeSendTransactionThrows_DropsTransactionAndRe // Assert _ = _fixture.BackgroundWorker.DidNotReceive().EnqueueEnvelope(Arg.Any()); _fixture.ClientReportRecorder.Received(1) - .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Transaction); + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Transaction); _fixture.ClientReportRecorder.Received(1) - .RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Span, transaction.Spans.Count + 1); + .RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Span, transaction.Spans.Count + 1); _fixture.SentryOptions.ReceivedLogError(exception, "The BeforeSendTransaction callback threw an exception. The transaction will be dropped."); transaction.Breadcrumbs.Should().BeEmpty(); } From f6ee5c567356cbddca365399d82e5fc198a38772 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Sep 2026 23:07:13 +1200 Subject: [PATCH 13/13] 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