diff --git a/CHANGELOG.md b/CHANGELOG.md index 15817dd70d3..acca00fc3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java index ccd7c5dc402..ba6d60e2563 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java @@ -113,6 +113,12 @@ public void recordLostEnvelopeItem( recordLostEventInternal( reason.getReason(), DataCategory.TraceMetricByte.getCategory(), metricBytes); executeOnDiscard(reason, itemCategory, count); + } else if (itemCategory.equals(DataCategory.Span)) { + // Batched span items (span streaming) aren't created by this SDK yet, but hybrid SDKs + // like sentry-dart send them through it via InternalSentrySdk.captureEnvelope. + final long count = itemCountFromHeader(envelopeItem); + recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), count); + executeOnDiscard(reason, itemCategory, count); } else { recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); executeOnDiscard(reason, itemCategory, 1L); @@ -156,9 +162,9 @@ private void recordLostEventInternal( storage.addCount(key, countToAdd); } - // The number of items batched into a log or metric envelope item is stored in its header, so we - // read it from there instead of deserializing the payload. Deserializing on the discard path is - // expensive and, under sustained rate limiting, caused a CPU busy-loop (JAVA-662). + // The number of items batched into a log, metric or span envelope item is stored in its header, + // so we read it from there instead of deserializing the payload. Deserializing on the discard + // path is expensive and, under sustained rate limiting, caused a CPU busy-loop (JAVA-662). private long itemCountFromHeader(final @NotNull SentryEnvelopeItem envelopeItem) { final @Nullable Integer itemCount = envelopeItem.getHeader().getItemCount(); return itemCount != null ? itemCount : 1L; diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 165f3c6c030..e077b25fb3c 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -59,6 +59,7 @@ import org.mockito.kotlin.whenever private const val LOG_CONTENT_TYPE = "application/vnd.sentry.items.log+json" private const val METRIC_CONTENT_TYPE = "application/vnd.sentry.items.trace-metric+json" +private const val SPAN_CONTENT_TYPE = "application/vnd.sentry.items.span.v2+json" class ClientReportTest { lateinit var opts: SentryOptions @@ -503,6 +504,64 @@ class ClientReportTest { assertEquals(1, clientReport.quantityOf(DataCategory.TraceMetric)) } + @Test + fun `recording lost span item reads count from the header`() { + val onDiscardMock = mock() + givenClientReportRecorder { options -> options.onDiscard = onDiscardMock } + + // Batched span items reach this SDK from hybrid SDKs (e.g. Flutter span streaming). + val item = + mockEnvelopeItem( + SentryItemType.Span, + SPAN_CONTENT_TYPE, + itemCount = 500, + data = "irrelevant payload".toByteArray(), + ) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, item) + + verify(onDiscardMock, times(1)).execute(DiscardReason.NETWORK_ERROR, DataCategory.Span, 500) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + assertEquals(500, clientReport.quantityOf(DataCategory.Span)) + } + + @Test + fun `recording rate limited span item reads count from the header`() { + givenClientReportRecorder() + + val item = + mockEnvelopeItem(SentryItemType.Span, SPAN_CONTENT_TYPE, itemCount = 7, data = ByteArray(0)) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.RATELIMIT_BACKOFF, item) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + val spanItem = + clientReport!!.discardedEvents!!.single { + it.category == DataCategory.Span.category && + it.reason == DiscardReason.RATELIMIT_BACKOFF.reason + } + assertEquals(7, spanItem.quantity) + } + + @Test + fun `recording lost span item without item count in header falls back to one`() { + givenClientReportRecorder() + + val item = + mockEnvelopeItem( + SentryItemType.Span, + SPAN_CONTENT_TYPE, + itemCount = null, + data = ByteArray(0), + ) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, item) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + assertEquals(1, clientReport.quantityOf(DataCategory.Span)) + } + private fun mockEnvelopeItem( type: SentryItemType, contentType: String,