diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5208deed6..2a98a40e0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fallback to distinct-id as user.id logging attribute when user is not set ([#4847](https://github.com/getsentry/sentry-java/pull/4847)) - Report Timber.tag() as `timber.tag` log attribute ([#4845](https://github.com/getsentry/sentry-java/pull/4845)) - Session Replay: Add screenshot strategy serialization to RRWeb events ([#4851](https://github.com/getsentry/sentry-java/pull/4851)) +- Report discarded log bytes ([#4871](https://github.com/getsentry/sentry-java/pull/4871)) - Log why a properties file was not loaded ([#4879](https://github.com/getsentry/sentry-java/pull/4879)) ### Dependencies diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 65bbbea1ac3..6dbc0f25723 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; @@ -7074,6 +7075,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 f4127e86ec2..982bc7f0db8 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1184,6 +1184,7 @@ public void captureLog(@Nullable SentryLogEvent logEvent, @Nullable IScope scope } if (logEvent != null) { + final @NotNull SentryLogEvent tmpLogEvent = logEvent; logEvent = executeBeforeSendLog(logEvent); if (logEvent == null) { @@ -1191,6 +1192,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..8da4360dcb2 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,9 +105,15 @@ 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 { + options.getLogger().log(SentryLevel.ERROR, "Unable to parse lost logs envelope item."); } } else { recordLostEventInternal(reason.getReason(), itemCategory.getCategory(), 1L); diff --git a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java index 83b4a03e8e2..fc9bc75aa3f 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 0 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") + } }