Repository navigation
Callback error isolation: remaining gaps after #5607 and #5610 #5634
Description
Follow-up to #5535. #5606, #5607 and #5610 isolate the managed callbacks and count what they drop: callback_error for a throw, the usual reason for a null return. This tracks what's left. Most of it is on native events, where the native SDK does the counting.
Spec: hooks spec 1.1.0 (candidate). When a callback fails, SDKs MUST log an error naming the callback, SHOULD drop the data passed to it, and SHOULD record a callback_error client report.
- 1. iOS native events are counted twice (needs a decision)
- 2. Native events can't be counted as
callback_error(blocked on fix(core): [Callback Errors 1] Report callback failures sentry-java#6141 and Isolate Objective-C user callback exceptions sentry-cocoa#8993) - 3. Exception filters and exception processors aren't isolated
- 4. SDK event processors are counted as
callback_error(minor) - 5. Each recorded drop allocates 24 B (independent, small)
1. iOS native events are counted twice
On iOS, ProcessOnBeforeSend runs the managed event processors and BeforeSend on native events:
sentry-dotnet/src/Sentry/Platforms/Cocoa/SentrySdk.cs
Lines 315 to 321 in f55e72e
| if (SentryEventHelper.ProcessEvent(sentryEvent, manualProcessors, null, options, DataCategory.Error) | |
| is not { } processedEvent) | |
| { | |
| return null; | |
| } | |
| processedEvent = SentryEventHelper.DoBeforeSend(processedEvent, new SentryHint(), options); |
SentryEventHelper records the drop:
sentry-dotnet/src/Sentry/Internal/SentryEventHelper.cs
Lines 26 to 60 in f55e72e
| 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; | |
| } | |
| if (processedEvent == null) | |
| { | |
| options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.EventProcessor, dataCategory); | |
| options.LogInfo("Event dropped by processor {0}", processor.GetType().Name); | |
| break; | |
| } | |
| } | |
| return processedEvent; | |
| } | |
| public static SentryEvent? DoBeforeSend(SentryEvent? @event, SentryHint hint, SentryOptions options) | |
| { | |
| if (@event is null || options.BeforeSendInternal is null) | |
| { | |
| return @event; | |
| } | |
| options.LogDebug("Calling the BeforeSend callback."); | |
| try | |
| { | |
| @event = options.BeforeSendInternal?.Invoke(@event, hint); | |
| if (@event == null) // Rejected event | |
| { | |
| options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.BeforeSend, DataCategory.Error); | |
| options.LogInfo("Event dropped by BeforeSend callback."); | |
| } | |
| } | |
| catch (Exception e) | |
| { | |
| options.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.CallbackError, DataCategory.Error); |
.NET then returns nil, and sentry-cocoa records before_send for the same event:
https://github.com/getsentry/sentry-cocoa/blob/9.29.2/Sources/Sentry/SentryClient.m#L1164-L1172
| Native event dropped because | .NET counts | sentry-cocoa counts |
|---|---|---|
Event processor returns null |
event_processor |
before_send |
| Event processor throws | callback_error |
before_send |
BeforeSend returns null |
before_send |
before_send |
BeforeSend throws |
callback_error |
before_send |
Main already double-counts a null return. For a throw, the two counts now also disagree on the reason.
Needs a decision. Skipping the .NET record for native events fixes the double count, but then a throw on a native iOS event only ever shows up as before_send, until 2 is done.
2. Native events can't be counted as callback_error
Neither native SDK has the reason yet: sentry-java 8.58.0 (DiscardReason.java) and sentry-cocoa 9.29.2 (SentryDiscardReason.swift).
- Android: when the user's
BeforeSendthrows, fix: an exception in BeforeSend or BeforeSendTransaction now drops the item instead of sending it #5610'sBeforeSendWrapperreturnsnull(Platforms/Android/SentrySdk.cs:246-255). sentry-java then countsbefore_send/error(SentryClient.java:171-177). - iOS: see 1.
Upstream work:
- fix(core): [Callback Errors 1] Report callback failures sentry-java#6141 (part of Align callback error handling with other SDKs sentry-java#6081) adds
DiscardReason.CALLBACK_ERRORand records it when abeforeSend*callback throws. sentry-java already drops the event on a throw; it just counts it asbefore_sendtoday. - Isolate Objective-C user callback exceptions sentry-cocoa#8993 plans an
@try/@catchboundary aroundSentryObjCcallbacks, which is the layer the .NET bindings use, and client reports for failures that drop telemetry.
To investigate once those ship: instead of returning null, the bridges could pass the failure through to the native SDK, by throwing a Java exception from BeforeSendCallback.Execute or an NSException from the Cocoa block. The native SDK would then drop the event and count callback_error itself, and .NET could stop recording for native events, which also fixes 1. This depends on how .NET for Android and .NET for iOS marshal managed exceptions across JNI and Objective-C, which needs checking on a device.
3. Exception filters and exception processors aren't isolated
IExceptionFilter.Filter (SentryClient.cs:449) and ISentryEventExceptionProcessor.Process (SentryClient.cs:364-367) have no try/catch of their own. A throw ends in SentryClient.CaptureEvent's catch-all (SentryClient.cs:84). The event is dropped and nothing is counted. The log, An error occurred when capturing the event '{0}'., doesn't name the callback.
That misses the spec's MUST (log naming the callback) and its SHOULD (callback_error). It's a managed-only fix, following the per-processor catch in SentryEventHelper.ProcessEvent.
4. SDK event processors are counted as callback_error (minor)
SentryEventHelper.ProcessEvent catches every event processor, the SDK's own included. The spec only requires isolating user-registered processors. A bug in an SDK processor would show up in the user's Stats as their callback failing. getsentry/sentry-java#6162 marks SDK-owned processors to tell them apart, which is one way to do it.
5. Each recorded drop allocates 24 B
ThreadsafeCounterDictionary.Add and Increment call _items.GetOrAdd(key, new CounterItem()) (ThreadsafeCounterDictionary.cs:27,33). That allocates a CounterItem on every call, even when the key already exists. #5607 adds call sites on hot paths, such as every dropped log or metric.
Measured: a dropped log went from 0 B to 24 B allocated. The factory overload, GetOrAdd(key, static _ => new CounterItem()), brings it back to 0 B. CollectionsExtensions.cs:10 already uses the factory overload. This one is independent of the rest and can be a small PR off main.
Activity
linear-code commentedon Sep 28, 2026
NET-826
jamescrosswell commentedon Oct 7, 2026
@ric-oliv I'd say we supress counting in .NET when we know the native SDK is going to do the counting. Eventually the Cocoa SDK will address/correct the reporting category for these and all will be well in the world.
callback_errorfor native Android events whenBeforeSendthrows #5682callback_errorfor native iOS events when a managed callback throws #5683IExceptionFilterandISentryEventExceptionProcessorfailures #5684callback_error#5685ThreadsafeCounterDictionaryallocates aCounterItemon every recorded drop #5686