From fed4e9a6379a4b3c8a6cabea037d8b38f19eee88 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 15 Oct 2026 13:45:02 +0200 Subject: [PATCH] fix(client-reports): Count spans in batched span envelope items Prior to this commit, `ClientReportRecorder` didn't have any explicit logic for dealing with span envelopes because sentry-java didn't implement them. That meant that any span envelopes created by hybrid SDKs (in particular sentry-dart) and dropped by sentry-java would be shunted to the recorder's fallback branch and wrongly register a single dropped span per dropped envelope, rather than the actual number. This commit adds the missing span envelope logic. Now sentry-java will report the actual span count, rather than 1. Refs [sentry-docs#19854](https://github.com/getsentry/sentry-docs/pull/19854) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 1 + .../clientreport/ClientReportRecorder.java | 12 +++- .../sentry/clientreport/ClientReportTest.kt | 59 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bd32c9e3b..cb6e3d91ce3 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)) - Measure Session Replay's 1h recording cap and its touch-move debounce on a monotonic clock, so a device time change no longer stops a healthy recording early, keeps a finished one alive, or suppresses gesture events ([#6090](https://github.com/getsentry/sentry-java/pull/6090)) ## 8.61.0 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,