From d2a38ccc2b0092051e9d387165ba7f38032f850b Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 3 Nov 2025 13:35:31 +0100 Subject: [PATCH 1/4] Fix log count in client reports --- .../clientreport/ClientReportRecorder.java | 15 +++++++++-- .../sentry/clientreport/ClientReportTest.kt | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java index 4e2383eb107..7b415ce48c9 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java @@ -6,6 +6,7 @@ import io.sentry.SentryEnvelopeItem; import io.sentry.SentryItemType; import io.sentry.SentryLevel; +import io.sentry.SentryLogEvents; import io.sentry.SentryOptions; import io.sentry.protocol.SentrySpan; import io.sentry.protocol.SentryTransaction; @@ -98,9 +99,19 @@ public void recordLostEnvelopeItem( reason.getReason(), DataCategory.Span.getCategory(), spans.size() + 1L); executeOnDiscard(reason, DataCategory.Span, spans.size() + 1L); } + recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); + executeOnDiscard(reason, itemCategory, 1L); + } else if (itemCategory.equals(DataCategory.LogItem)) { + final @Nullable SentryLogEvents logs = envelopeItem.getLogs(options.getSerializer()); + if (logs != null) { + final long count = logs.getItems().size(); + recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), count); + executeOnDiscard(reason, itemCategory, count); + } + } else { + recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); + executeOnDiscard(reason, itemCategory, 1L); } - recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); - executeOnDiscard(reason, itemCategory, 1L); } } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, e, "Unable to record lost envelope item."); diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 2b34fd839b0..eae216239aa 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -16,6 +16,10 @@ import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader import io.sentry.SentryEnvelopeItem import io.sentry.SentryEvent +import io.sentry.SentryLogEvent +import io.sentry.SentryLogEvents +import io.sentry.SentryLogLevel +import io.sentry.SentryLongDate import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryTracer @@ -347,6 +351,29 @@ class ClientReportTest { verify(onDiscardMock, times(1)).execute(DiscardReason.BEFORE_SEND, DataCategory.Profile, 1) } + @Test + fun `recording lost client report counts log entries`() { + val onDiscardMock = mock() + givenClientReportRecorder { options -> options.onDiscard = onDiscardMock } + + val envelope = + testHelper.newEnvelope( + SentryEnvelopeItem.fromLogs( + opts.serializer, + SentryLogEvents( + listOf( + SentryLogEvent(SentryId(), SentryLongDate(1), "log message 1", SentryLogLevel.ERROR), + SentryLogEvent(SentryId(), SentryLongDate(2), "log message 2", SentryLogLevel.WARN), + ) + ), + ) + ) + + clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, envelope.items.first()) + + verify(onDiscardMock, times(1)).execute(DiscardReason.NETWORK_ERROR, DataCategory.LogItem, 2) + } + private fun givenClientReportRecorder( callback: Sentry.OptionsConfiguration? = null ) { From 3bd2cf0e1d48489ad7e94fe945ea4bdcdff2adc0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 3 Nov 2025 15:29:02 +0100 Subject: [PATCH 2/4] add assertion to test --- .../src/test/java/io/sentry/clientreport/ClientReportTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index eae216239aa..30cfece6267 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -372,6 +372,10 @@ class ClientReportTest { clientReportRecorder.recordLostEnvelopeItem(DiscardReason.NETWORK_ERROR, envelope.items.first()) verify(onDiscardMock, times(1)).execute(DiscardReason.NETWORK_ERROR, DataCategory.LogItem, 2) + + val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() + val logItem = clientReport!!.discardedEvents!!.first { it.category == DataCategory.LogItem.category } + assertEquals(2, logItem.quantity) } private fun givenClientReportRecorder( From dc552dd9533e6c661d2258ad39752ea41127049e Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 5 Nov 2025 12:39:36 +0000 Subject: [PATCH 3/4] Format code --- .../src/test/java/io/sentry/clientreport/ClientReportTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 30cfece6267..4316af5a2bc 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -374,7 +374,8 @@ class ClientReportTest { verify(onDiscardMock, times(1)).execute(DiscardReason.NETWORK_ERROR, DataCategory.LogItem, 2) val clientReport = clientReportRecorder.resetCountsAndGenerateClientReport() - val logItem = clientReport!!.discardedEvents!!.first { it.category == DataCategory.LogItem.category } + val logItem = + clientReport!!.discardedEvents!!.first { it.category == DataCategory.LogItem.category } assertEquals(2, logItem.quantity) } From b703d2ea4e84ac15dae78cfcf56a1b1478b6b2ce Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 5 Nov 2025 14:07:30 +0100 Subject: [PATCH 4/4] report discarded bytes --- sentry/api/sentry.api | 2 + .../src/main/java/io/sentry/DataCategory.java | 1 + .../src/main/java/io/sentry/SentryClient.java | 8 ++ .../clientreport/ClientReportRecorder.java | 7 +- .../sentry/util/JsonSerializationUtils.java | 86 +++++++++++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 10 ++- .../sentry/clientreport/ClientReportTest.kt | 3 + .../sentry/util/JsonSerializationUtilsTest.kt | 63 ++++++++++++++ 8 files changed, 177 insertions(+), 3 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2211f053912..05c789dbea9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -347,6 +347,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static final field Default Lio/sentry/DataCategory; public static final field Error Lio/sentry/DataCategory; public static final field Feedback Lio/sentry/DataCategory; + public static final field LogByte Lio/sentry/DataCategory; public static final field LogItem Lio/sentry/DataCategory; public static final field Monitor Lio/sentry/DataCategory; public static final field Profile Lio/sentry/DataCategory; @@ -7062,6 +7063,7 @@ public final class io/sentry/util/IntegrationUtils { public final class io/sentry/util/JsonSerializationUtils { public fun ()V public static fun atomicIntegerArrayToList (Ljava/util/concurrent/atomic/AtomicIntegerArray;)Ljava/util/List; + public static fun byteSizeOf (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/JsonSerializable;)J public static fun bytesFrom (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/JsonSerializable;)[B public static fun calendarToMap (Ljava/util/Calendar;)Ljava/util/Map; } diff --git a/sentry/src/main/java/io/sentry/DataCategory.java b/sentry/src/main/java/io/sentry/DataCategory.java index 226deef9a69..ca60f8bc5d4 100644 --- a/sentry/src/main/java/io/sentry/DataCategory.java +++ b/sentry/src/main/java/io/sentry/DataCategory.java @@ -10,6 +10,7 @@ public enum DataCategory { Session("session"), Attachment("attachment"), LogItem("log_item"), + LogByte("log_byte"), Monitor("monitor"), Profile("profile"), ProfileChunkUi("profile_chunk_ui"), diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index bfcf4e780be..522b6bb34c3 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1183,6 +1183,7 @@ public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope } if (logEvent != null) { + final @NotNull SentryLogEvent tmpLogEvent = logEvent; logEvent = executeBeforeSendLog(logEvent); if (logEvent == null) { @@ -1190,6 +1191,13 @@ public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope options .getClientReportRecorder() .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.LogItem); + final @NotNull long logEventNumberOfBytes = + JsonSerializationUtils.byteSizeOf( + options.getSerializer(), options.getLogger(), tmpLogEvent); + options + .getClientReportRecorder() + .recordLostEvent( + DiscardReason.BEFORE_SEND, DataCategory.LogByte, logEventNumberOfBytes); return; } diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java index 7b415ce48c9..7c872a0cd19 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReportRecorder.java @@ -6,6 +6,7 @@ import io.sentry.SentryEnvelopeItem; import io.sentry.SentryItemType; import io.sentry.SentryLevel; +import io.sentry.SentryLogEvent; import io.sentry.SentryLogEvents; import io.sentry.SentryOptions; import io.sentry.protocol.SentrySpan; @@ -104,8 +105,12 @@ public void recordLostEnvelopeItem( } else if (itemCategory.equals(DataCategory.LogItem)) { final @Nullable SentryLogEvents logs = envelopeItem.getLogs(options.getSerializer()); if (logs != null) { - final long count = logs.getItems().size(); + final @NotNull List items = logs.getItems(); + final long count = items.size(); recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), count); + final long logBytes = envelopeItem.getData().length; + recordLostEventInternal( + reason.getReason(), DataCategory.LogByte.getCategory(), logBytes); executeOnDiscard(reason, itemCategory, count); } } else { diff --git a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java index 83b4a03e8e2..694ff32f45d 100644 --- a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java +++ b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java @@ -65,4 +65,90 @@ public final class JsonSerializationUtils { return null; } } + + /** + * Calculates the size in bytes of a serializable object when serialized to JSON without actually + * storing the serialized data. This is more memory efficient than {@link #bytesFrom(ISerializer, + * ILogger, JsonSerializable)} when you only need the size. + * + * @param serializer the serializer + * @param logger the logger + * @param serializable the serializable object + * @return the size in bytes, or -1 if serialization fails + */ + public static long byteSizeOf( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @Nullable JsonSerializable serializable) { + if (serializable == null) { + return 0; + } + try { + final ByteCountingWriter writer = new ByteCountingWriter(); + serializer.serialize(serializable, writer); + return writer.getByteCount(); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not calculate size of serializable", t); + return 0; + } + } + + /** + * A Writer that counts the number of bytes that would be written in UTF-8 encoding without + * actually storing the data. + */ + private static final class ByteCountingWriter extends Writer { + private long byteCount = 0L; + + @Override + public void write(final char[] cbuf, final int off, final int len) { + for (int i = off; i < off + len; i++) { + byteCount += utf8ByteCount(cbuf[i]); + } + } + + @Override + public void write(final int c) { + byteCount += utf8ByteCount((char) c); + } + + @Override + public void write(final @NotNull String str, final int off, final int len) { + for (int i = off; i < off + len; i++) { + byteCount += utf8ByteCount(str.charAt(i)); + } + } + + @Override + public void flush() { + // Nothing to flush since we don't store data + } + + @Override + public void close() { + // Nothing to close + } + + public long getByteCount() { + return byteCount; + } + + /** + * Calculates the number of bytes needed to encode a character in UTF-8. + * + * @param c the character + * @return the number of bytes (1-4) + */ + private static int utf8ByteCount(final char c) { + if (c <= 0x7F) { + return 1; // ASCII + } else if (c <= 0x7FF) { + return 2; // 2-byte character + } else if (Character.isSurrogate(c)) { + return 2; // Surrogate pair, counted as 2 bytes each (total 4 for the pair) + } else { + return 3; // 3-byte character + } + } + } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 22f689abac0..6d6165e0e85 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -293,7 +293,10 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, - listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogItem.category, 1)), + listOf( + DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogItem.category, 1), + DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogByte.category, 109), + ), ) } @@ -312,7 +315,10 @@ class SentryClientTest { assertClientReport( fixture.sentryOptions.clientReportRecorder, - listOf(DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogItem.category, 1)), + listOf( + DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogItem.category, 1), + DiscardedEvent(DiscardReason.BEFORE_SEND.reason, DataCategory.LogByte.category, 109), + ), ) } diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 4316af5a2bc..ae4a5f35362 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -377,6 +377,9 @@ class ClientReportTest { val logItem = clientReport!!.discardedEvents!!.first { it.category == DataCategory.LogItem.category } assertEquals(2, logItem.quantity) + val logByte = + clientReport!!.discardedEvents!!.first { it.category == DataCategory.LogByte.category } + assertEquals(226, logByte.quantity) } private fun givenClientReportRecorder( diff --git a/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt b/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt index 835182ed105..caa93f1af7a 100644 --- a/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt @@ -3,6 +3,11 @@ package io.sentry.util import io.sentry.ILogger import io.sentry.JsonSerializable import io.sentry.JsonSerializer +import io.sentry.ObjectWriter +import io.sentry.SentryLogEvent +import io.sentry.SentryLogLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId import java.io.Writer import java.util.Calendar import java.util.concurrent.atomic.AtomicIntegerArray @@ -10,11 +15,16 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue import org.mockito.invocation.InvocationOnMock import org.mockito.kotlin.any import org.mockito.kotlin.mock class JsonSerializationUtilsTest { + + private val serializer = JsonSerializer(SentryOptions()) + private val logger: ILogger = mock() + @Test fun `serializes calendar to map`() { val calendar = Calendar.getInstance() @@ -74,4 +84,57 @@ class JsonSerializationUtilsTest { assertNull(actualBytes, "Mocker error should be captured and null returned.") } + + @Test + fun `byteSizeOf returns same size as bytesFrom for ASCII`() { + val logEvent = SentryLogEvent(SentryId(), 1234567890.0, "Hello ASCII", SentryLogLevel.INFO) + + val actualBytes = JsonSerializationUtils.bytesFrom(serializer, logger, logEvent) + val byteSize = JsonSerializationUtils.byteSizeOf(serializer, logger, logEvent) + + assertEquals( + (actualBytes?.size ?: -1).toLong(), + byteSize, + "byteSizeOf should match actual byte array length", + ) + assertTrue(byteSize > 0, "byteSize should be positive") + } + + @Test + fun `byteSizeOf returns same size as bytesFrom for UTF-8 characters`() { + // Mix of 1-byte, 2-byte, 3-byte and 4-byte UTF-8 characters + val logEvent = + SentryLogEvent(SentryId(), 1234567890.0, "Hello 世界 café 🎉 🚀", SentryLogLevel.WARN) + + val actualBytes = JsonSerializationUtils.bytesFrom(serializer, logger, logEvent) + val byteSize = JsonSerializationUtils.byteSizeOf(serializer, logger, logEvent) + + assertEquals( + (actualBytes?.size ?: -1).toLong(), + byteSize, + "byteSizeOf should match actual byte array length for UTF-8", + ) + assertTrue(byteSize > 0, "byteSize should be positive") + } + + @Test + fun `byteSizeOf returns 0 on serialization error`() { + val serializable = + object : JsonSerializable { + override fun serialize(writer: ObjectWriter, logger: ILogger) { + throw RuntimeException("Serialization error") + } + } + + val byteSize = JsonSerializationUtils.byteSizeOf(serializer, logger, serializable) + + assertEquals(0, byteSize, "byteSizeOf should return 0 on error") + } + + @Test + fun `byteSizeOf returns 0 on null serializable`() { + val byteSize = JsonSerializationUtils.byteSizeOf(serializer, logger, null) + + assertEquals(0, byteSize, "byteSizeOf should return 0 on null serializable") + } }