Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@

- Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140))
- Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value.
- Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report.
- Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124)
- Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003))

Expand Down
37 changes: 37 additions & 0 deletions sentry/src/main/java/io/sentry/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.sentry.hints.Cached;
import io.sentry.hints.DiskFlushNotification;
import io.sentry.hints.TransactionEnd;
import io.sentry.internal.eventprocessor.SentryEventProcessor;
import io.sentry.logger.ILoggerBatchProcessor;
import io.sentry.logger.NoOpLoggerBatchProcessor;
import io.sentry.metrics.IMetricsBatchProcessor;
Expand Down Expand Up @@ -506,6 +507,12 @@ private SentryEvent processEvent(
e,
"An exception occurred while processing event by processor: %s",
processor.getClass().getName());
if (!(processor instanceof SentryEventProcessor)) {
options
.getClientReportRecorder()
.recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Error);
return null;
}
}

if (event == null) {
Expand Down Expand Up @@ -557,6 +564,10 @@ private SentryLogEvent processLogEvent(
e,
"An exception occurred while processing log event by processor: %s",
processor.getClass().getName());
if (!(processor instanceof SentryEventProcessor)) {
recordLostLogEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor);
return null;
}
}

if (event == null) {
Comment on lines 564 to 573

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When a transaction is dropped by a processTransaction event processor, the subsequent debug log message incorrectly attributes the drop to applyScope.
Severity: LOW

Suggested Fix

Update the debug log message to accurately reflect the source of the transaction drop. This could involve adding a separate log message within the processTransaction method when an exception occurs, or adding a check to differentiate between a drop from applyScope and a drop from processTransaction before logging.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: sentry/src/main/java/io/sentry/SentryClient.java#L564-L573

Potential issue: When a transaction is captured, if a scope event processor throws an
exception, the `processTransaction` method correctly returns `null` to drop the
transaction. However, a subsequent debug log message at `SentryLevel.DEBUG` incorrectly
states "Transaction was dropped by applyScope". This is misleading because the drop was
caused by the event processor in `processTransaction`, not by the `applyScope` method
itself. While the functional behavior of dropping the transaction and recording a
`CALLBACK_ERROR` is correct, the log message is inaccurate and could cause confusion
during debugging.

Expand Down Expand Up @@ -590,6 +601,10 @@ private SentryMetricsEvent processMetricsEvent(
e,
"An exception occurred while processing metrics event by processor: %s",
processor.getClass().getName());
if (!(processor instanceof SentryEventProcessor)) {
recordLostMetricsEvent(DiscardReason.CALLBACK_ERROR, eventBeforeProcessor);
return null;
}
}

if (event == null) {
Expand Down Expand Up @@ -622,6 +637,16 @@ private SentryMetricsEvent processMetricsEvent(
e,
"An exception occurred while processing transaction by processor: %s",
processor.getClass().getName());
if (!(processor instanceof SentryEventProcessor)) {
options
.getClientReportRecorder()
.recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction);
options
.getClientReportRecorder()
.recordLostEvent(
DiscardReason.CALLBACK_ERROR, DataCategory.Span, spanCountBeforeProcessor + 1);
return null;
}
}
final int spanCountAfterProcessor = transaction == null ? 0 : transaction.getSpans().size();

Expand Down Expand Up @@ -675,6 +700,12 @@ private SentryReplayEvent processReplayEvent(
e,
"An exception occurred while processing replay event by processor: %s",
processor.getClass().getName());
if (!(processor instanceof SentryEventProcessor)) {
options
.getClientReportRecorder()
.recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Replay);
return null;
}
}

if (replayEvent == null) {
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -709,6 +740,12 @@ private SentryEvent processFeedbackEvent(
e,
"An exception occurred while processing feedback event by processor: %s",
processor.getClass().getName());
if (!(processor instanceof SentryEventProcessor)) {
options
.getClientReportRecorder()
.recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Feedback);
return null;
}
}

if (feedbackEvent == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package io.sentry

import com.google.common.truth.Truth.assertThat
import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport
import io.sentry.clientreport.DiscardReason
import io.sentry.clientreport.DiscardedEvent
import io.sentry.internal.eventprocessor.SentryEventProcessor
import io.sentry.protocol.Feedback
import io.sentry.protocol.SentryId
import io.sentry.protocol.SentryTransaction
import kotlin.test.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.check
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.same
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever

@RunWith(Parameterized::class)
class SentryClientInternalEventProcessorTest(private val onScope: Boolean) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "onScope={0}")
fun data(): List<Array<Boolean>> = listOf(arrayOf(false), arrayOf(true))
}

private val fixture = SentryClientTest.Fixture()
private val options = fixture.sentryOptions
private val scope = Scope(options)
private val processor = mock<SentryEventProcessor>()
private val nextProcessor = mock<EventProcessor>()
private val onDiscard = mock<SentryOptions.OnDiscardCallback>()
private val logger = mock<ILogger>()
private val failure = IllegalStateException("SDK processor failed")

init {
options.eventProcessors.clear()
options.onDiscard = onDiscard
options.setLogger(logger)
options.logs.isEnabled = true
options.metrics.isEnabled = true
if (onScope) {
scope.addEventProcessor(processor)
scope.addEventProcessor(nextProcessor)
} else {
options.addEventProcessor(processor)
options.addEventProcessor(nextProcessor)
}
}

@Test
fun `SDK event processor failure keeps event and runs remaining callbacks`() {
val event = SentryEvent()
val beforeSend = mock<SentryOptions.BeforeSendCallback>()
whenever(processor.process(any<SentryEvent>(), any())).thenThrow(failure)
whenever(nextProcessor.process(any<SentryEvent>(), any())).thenAnswer { it.arguments[0] }
whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] }
options.beforeSend = beforeSend

val id = fixture.getSut().captureEvent(event, scope)

assertThat(id).isEqualTo(event.eventId)
verify(nextProcessor).process(same(event), any())
verify(beforeSend).execute(same(event), any())
verify(fixture.transport)
.send(check { assertThat(it.header.eventId).isEqualTo(id) }, anyOrNull())
assertFailureLoggedWithoutLoss("event")
}

@Test
fun `SDK transaction processor failure keeps transaction and spans`() {
val transaction = SentryTransaction(fixture.sentryTracer)
val beforeSend = mock<SentryOptions.BeforeSendTransactionCallback>()
whenever(processor.process(any<SentryTransaction>(), any())).thenThrow(failure)
whenever(nextProcessor.process(any<SentryTransaction>(), any())).thenAnswer { it.arguments[0] }
whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] }
options.beforeSendTransaction = beforeSend

val id = fixture.getSut().captureTransaction(transaction, scope, null)

assertThat(id).isEqualTo(transaction.eventId)
verify(nextProcessor).process(same(transaction), any())
verify(beforeSend).execute(same(transaction), any())
verify(fixture.transport)
.send(
check {
val sent = it.items.first().getTransaction(options.serializer)!!
assertThat(sent.eventId).isEqualTo(id)
assertThat(sent.spans).hasSize(1)
},
anyOrNull(),
)
assertFailureLoggedWithoutLoss("transaction")
}

@Test
fun `SDK feedback processor failure keeps feedback and runs remaining callbacks`() {
val feedback = Feedback("message")
val beforeSend = mock<SentryOptions.BeforeSendCallback>()
whenever(processor.process(any<SentryEvent>(), any())).thenThrow(failure)
whenever(nextProcessor.process(any<SentryEvent>(), any())).thenAnswer { it.arguments[0] }
whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] }
options.beforeSendFeedback = beforeSend

val id = fixture.getSut().captureFeedback(feedback, null, scope)

assertThat(id).isNotEqualTo(SentryId.EMPTY_ID)
verify(nextProcessor)
.process(
check<SentryEvent> {
assertThat(it.contexts.feedback).isSameInstanceAs(feedback)
},
any(),
)
verify(beforeSend).execute(check { assertThat(it.eventId).isEqualTo(id) }, any())
verify(fixture.transport)
.send(check { assertThat(it.header.eventId).isEqualTo(id) }, anyOrNull())
assertFailureLoggedWithoutLoss("feedback event")
}

@Test
fun `SDK log processor failure keeps log and runs remaining callbacks`() {
val event = SentryLogEvent(SentryId(), SentryNanotimeDate(), "message", SentryLogLevel.WARN)
val beforeSend = mock<SentryOptions.Logs.BeforeSendLogCallback>()
whenever(processor.process(any<SentryLogEvent>())).thenThrow(failure)
whenever(nextProcessor.process(any<SentryLogEvent>())).thenAnswer { it.arguments[0] }
whenever(beforeSend.execute(any())).thenAnswer { it.arguments[0] }
options.logs.beforeSend = beforeSend

fixture.getSut().captureLog(event, scope)

verify(nextProcessor).process(same(event))
verify(beforeSend).execute(same(event))
verify(fixture.loggerBatchProcessor).add(same(event))
assertFailureLoggedWithoutLoss("log event")
}

@Test
fun `SDK metric processor failure keeps metric and runs remaining callbacks`() {
val event = SentryMetricsEvent(SentryId(), SentryNanotimeDate(), "name", "gauge", 123.0)
val beforeSend = mock<SentryOptions.Metrics.BeforeSendMetricCallback>()
whenever(processor.process(any<SentryMetricsEvent>(), any())).thenThrow(failure)
whenever(nextProcessor.process(any<SentryMetricsEvent>(), any())).thenAnswer { it.arguments[0] }
whenever(beforeSend.execute(any(), any())).thenAnswer { it.arguments[0] }
options.metrics.beforeSend = beforeSend

fixture.getSut().captureMetric(event, scope, null)

verify(nextProcessor).process(same(event), any())
verify(beforeSend).execute(same(event), any())
verify(fixture.metricsBatchProcessor).add(same(event))
assertFailureLoggedWithoutLoss("metrics event")
}

@Test
fun `SDK processor returning null still drops event as event_processor`() {
whenever(processor.process(any<SentryEvent>(), any())).thenReturn(null)

val id = fixture.getSut().captureEvent(SentryEvent(), scope)

assertThat(id).isEqualTo(SentryId.EMPTY_ID)
verify(nextProcessor, never()).process(any<SentryEvent>(), any())
verify(fixture.transport, never()).send(any(), anyOrNull())
assertClientReport(
options.clientReportRecorder,
listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Error.category, 1)),
)
}

@Test
fun `customer processor failure after SDK processor failure still drops event`() {
whenever(processor.process(any<SentryEvent>(), any())).thenThrow(failure)
whenever(nextProcessor.process(any<SentryEvent>(), any()))
.thenThrow(IllegalArgumentException("customer"))
val beforeSend = mock<SentryOptions.BeforeSendCallback>()
options.beforeSend = beforeSend

val id = fixture.getSut().captureEvent(SentryEvent(), scope)

assertThat(id).isEqualTo(SentryId.EMPTY_ID)
verify(nextProcessor).process(any<SentryEvent>(), any())
verifyNoInteractions(beforeSend)
verify(fixture.transport, never()).send(any(), anyOrNull())
assertClientReport(
options.clientReportRecorder,
listOf(DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Error.category, 1)),
)
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Error, 1)
}

@Test
fun `spans removed before SDK processor failure retain event_processor accounting`() {
val transaction = SentryTransaction(fixture.sentryTracer)
whenever(processor.process(any<SentryTransaction>(), any())).doAnswer {
transaction.spans.clear()
throw failure
}
whenever(nextProcessor.process(any<SentryTransaction>(), any())).thenAnswer { it.arguments[0] }

val id = fixture.getSut().captureTransaction(transaction, scope, null)

assertThat(id).isEqualTo(transaction.eventId)
verify(nextProcessor).process(same(transaction), any())
verify(fixture.transport).send(any(), anyOrNull())
assertClientReport(
options.clientReportRecorder,
listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Span.category, 1)),
)
}

private fun assertFailureLoggedWithoutLoss(item: String) {
verify(logger)
.log(
eq(SentryLevel.ERROR),
same(failure),
eq("An exception occurred while processing $item by processor: %s"),
eq(processor.javaClass.name),
)
assertClientReport(options.clientReportRecorder, emptyList())
verifyNoInteractions(onDiscard)
}
}
Loading
Loading