diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fb241c9b4e..b3f7a5c275 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3417,6 +3417,7 @@ public class io/sentry/SentryOptions { public fun getMaxTraceFileSize ()J public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader; public fun getOnDiscard ()Lio/sentry/SentryOptions$OnDiscardCallback; + public fun getOnOversizedEvent ()Lio/sentry/SentryOptions$OnOversizedEventCallback; public fun getOpenTelemetryMode ()Lio/sentry/SentryOpenTelemetryMode; public fun getOptionsObservers ()Ljava/util/List; public fun getOutboxPath ()Ljava/lang/String; @@ -3468,6 +3469,7 @@ public class io/sentry/SentryOptions { public fun isEnableAutoSessionTracking ()Z public fun isEnableBackpressureHandling ()Z public fun isEnableDeduplication ()Z + public fun isEnableEventSizeLimiting ()Z public fun isEnableExternalConfiguration ()Z public fun isEnablePrettySerializationOutput ()Z public fun isEnableScopePersistence ()Z @@ -3524,6 +3526,7 @@ public class io/sentry/SentryOptions { public fun setEnableAutoSessionTracking (Z)V public fun setEnableBackpressureHandling (Z)V public fun setEnableDeduplication (Z)V + public fun setEnableEventSizeLimiting (Z)V public fun setEnableExternalConfiguration (Z)V public fun setEnablePrettySerializationOutput (Z)V public fun setEnableScopePersistence (Z)V @@ -3566,6 +3569,7 @@ public class io/sentry/SentryOptions { public fun setMaxTraceFileSize (J)V public fun setModulesLoader (Lio/sentry/internal/modules/IModulesLoader;)V public fun setOnDiscard (Lio/sentry/SentryOptions$OnDiscardCallback;)V + public fun setOnOversizedEvent (Lio/sentry/SentryOptions$OnOversizedEventCallback;)V public fun setOpenTelemetryMode (Lio/sentry/SentryOpenTelemetryMode;)V public fun setPrintUncaughtStackTrace (Z)V public fun setProfileLifecycle (Lio/sentry/ProfileLifecycle;)V @@ -3676,6 +3680,10 @@ public abstract interface class io/sentry/SentryOptions$OnDiscardCallback { public abstract fun execute (Lio/sentry/clientreport/DiscardReason;Lio/sentry/DataCategory;Ljava/lang/Long;)V } +public abstract interface class io/sentry/SentryOptions$OnOversizedEventCallback { + public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; +} + public abstract interface class io/sentry/SentryOptions$ProfilesSamplerCallback { public abstract fun sample (Lio/sentry/SamplingContext;)Ljava/lang/Double; } @@ -7124,6 +7132,10 @@ public final class io/sentry/util/EventProcessorUtils { public static fun unwrap (Ljava/util/List;)Ljava/util/List; } +public final class io/sentry/util/EventSizeLimitingUtils { + public static fun limitEventSize (Lio/sentry/SentryEvent;Lio/sentry/Hint;Lio/sentry/SentryOptions;)Lio/sentry/SentryEvent; +} + public final class io/sentry/util/ExceptionUtils { public fun ()V public static fun findRootCause (Ljava/lang/Throwable;)Ljava/lang/Throwable; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 78e5d01374..73ff534dad 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -164,6 +164,10 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } } + if (event != null) { + event = EventSizeLimitingUtils.limitEventSize(event, hint, options); + } + if (event == null) { return SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 37acb54150..368d912195 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -353,6 +353,18 @@ public class SentryOptions { */ private boolean enableDeduplication = true; + /** + * Enables event size limiting with {@link EventSizeLimitingEventProcessor}. When enabled, events + * exceeding 1MB will have breadcrumbs and stack frames reduced to stay under the limit. + */ + private boolean enableEventSizeLimiting = false; + + /** + * Callback invoked when an oversized event is detected. This allows custom handling of oversized + * events before the automatic reduction steps are applied. + */ + private @Nullable OnOversizedEventCallback onOversizedEvent; + /** Maximum number of spans that can be atteched to single transaction. */ private int maxSpans = 1000; @@ -1752,6 +1764,44 @@ public void setEnableDeduplication(final boolean enableDeduplication) { this.enableDeduplication = enableDeduplication; } + /** + * Returns if event size limiting is enabled. + * + * @return true if event size limiting is enabled, false otherwise + */ + public boolean isEnableEventSizeLimiting() { + return enableEventSizeLimiting; + } + + /** + * Enables or disables event size limiting. When enabled, events exceeding 1MB will have + * breadcrumbs and stack frames reduced to stay under the limit. + * + * @param enableEventSizeLimiting true to enable, false to disable + */ + public void setEnableEventSizeLimiting(final boolean enableEventSizeLimiting) { + this.enableEventSizeLimiting = enableEventSizeLimiting; + } + + /** + * Returns the onOversizedEvent callback. + * + * @return the onOversizedEvent callback or null if not set + */ + public @Nullable OnOversizedEventCallback getOnOversizedEvent() { + return onOversizedEvent; + } + + /** + * Sets the onOversizedEvent callback. This callback is invoked when an oversized event is + * detected, before the automatic reduction steps are applied. + * + * @param onOversizedEvent the onOversizedEvent callback + */ + public void setOnOversizedEvent(@Nullable OnOversizedEventCallback onOversizedEvent) { + this.onOversizedEvent = onOversizedEvent; + } + /** * Returns if tracing should be enabled. If tracing is disabled, starting transactions returns * {@link NoOpTransaction}. @@ -3136,6 +3186,21 @@ public interface BeforeBreadcrumbCallback { Breadcrumb execute(@NotNull Breadcrumb breadcrumb, @NotNull Hint hint); } + /** The OnOversizedEvent callback */ + public interface OnOversizedEventCallback { + + /** + * Called when an oversized event is detected. This callback allows custom handling of oversized + * events before automatic reduction steps are applied. + * + * @param event the oversized event + * @param hint the hints + * @return the modified event (should ideally be reduced in size) + */ + @NotNull + SentryEvent execute(@NotNull SentryEvent event, @NotNull Hint hint); + } + /** The OnDiscard callback */ public interface OnDiscardCallback { diff --git a/sentry/src/main/java/io/sentry/util/EventSizeLimitingUtils.java b/sentry/src/main/java/io/sentry/util/EventSizeLimitingUtils.java new file mode 100644 index 0000000000..f291e119e6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/EventSizeLimitingUtils.java @@ -0,0 +1,177 @@ +package io.sentry.util; + +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.SentryStackTrace; +import io.sentry.protocol.SentryThread; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Utility class that limits event size to 1MB by incrementally dropping fields when the event + * exceeds the limit. + */ +@ApiStatus.Internal +public final class EventSizeLimitingUtils { + + private static final long MAX_EVENT_SIZE_BYTES = 1024 * 1024; + private static final int FRAMES_PER_SIDE = 250; + + private EventSizeLimitingUtils() {} + + /** + * Limits the size of an event by incrementally dropping fields when it exceeds the limit. + * + * @param event the event to limit + * @param hint the hint + * @param options the SentryOptions + * @return the potentially reduced event + */ + public static @Nullable SentryEvent limitEventSize( + final @NotNull SentryEvent event, + final @NotNull Hint hint, + final @NotNull SentryOptions options) { + try { + if (!options.isEnableEventSizeLimiting()) { + return event; + } + + if (isSizeOk(event, options)) { + return event; + } + + options + .getLogger() + .log( + SentryLevel.INFO, + "Event %s exceeds %d bytes limit. Reducing size by dropping fields.", + event.getEventId(), + MAX_EVENT_SIZE_BYTES); + + @NotNull SentryEvent reducedEvent = event; + + final @Nullable SentryOptions.OnOversizedEventCallback callback = + options.getOnOversizedEvent(); + if (callback != null) { + try { + reducedEvent = callback.execute(reducedEvent, hint); + if (isSizeOk(reducedEvent, options)) { + return reducedEvent; + } + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "The onOversizedEvent callback threw an exception. It will be ignored and automatic reduction will continue.", + e); + reducedEvent = event; + } + } + + reducedEvent = removeAllBreadcrumbs(reducedEvent, options); + if (isSizeOk(reducedEvent, options)) { + return reducedEvent; + } + + reducedEvent = truncateStackFrames(reducedEvent, options); + if (!isSizeOk(reducedEvent, options)) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Event %s still exceeds size limit after reducing all fields. Event may be rejected by server.", + event.getEventId()); + } + + return reducedEvent; + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "An error occurred while limiting event size. Event will be sent as-is.", + e); + return event; + } + } + + private static boolean isSizeOk( + final @NotNull SentryEvent event, final @NotNull SentryOptions options) { + final long size = + JsonSerializationUtils.byteSizeOf(options.getSerializer(), options.getLogger(), event); + return size <= MAX_EVENT_SIZE_BYTES; + } + + private static @NotNull SentryEvent removeAllBreadcrumbs( + final @NotNull SentryEvent event, final @NotNull SentryOptions options) { + final List breadcrumbs = event.getBreadcrumbs(); + if (breadcrumbs != null && !breadcrumbs.isEmpty()) { + event.setBreadcrumbs(null); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Removed breadcrumbs to reduce size of event %s", + event.getEventId()); + } + return event; + } + + private static @NotNull SentryEvent truncateStackFrames( + final @NotNull SentryEvent event, final @NotNull SentryOptions options) { + final @Nullable List exceptions = event.getExceptions(); + if (exceptions != null) { + for (final @NotNull SentryException exception : exceptions) { + final @Nullable SentryStackTrace stacktrace = exception.getStacktrace(); + if (stacktrace != null) { + final @Nullable List frames = stacktrace.getFrames(); + if (frames != null && frames.size() > (FRAMES_PER_SIDE * 2)) { + final @NotNull List truncatedFrames = new ArrayList<>(); + truncatedFrames.addAll(frames.subList(0, FRAMES_PER_SIDE)); + truncatedFrames.addAll(frames.subList(frames.size() - FRAMES_PER_SIDE, frames.size())); + stacktrace.setFrames(truncatedFrames); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Truncated exception stack frames of event %s", + event.getEventId()); + } + } + } + } + + final @Nullable List threads = event.getThreads(); + if (threads != null) { + for (final SentryThread thread : threads) { + final @Nullable SentryStackTrace stacktrace = thread.getStacktrace(); + if (stacktrace != null) { + final @Nullable List frames = stacktrace.getFrames(); + if (frames != null && frames.size() > (FRAMES_PER_SIDE * 2)) { + final @NotNull List truncatedFrames = new ArrayList<>(); + truncatedFrames.addAll(frames.subList(0, FRAMES_PER_SIDE)); + truncatedFrames.addAll(frames.subList(frames.size() - FRAMES_PER_SIDE, frames.size())); + stacktrace.setFrames(truncatedFrames); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Truncated thread stack frames for event %s", + event.getEventId()); + } + } + } + } + + return event; + } +} diff --git a/sentry/src/test/java/io/sentry/EventSizeLimitingUtilsTest.kt b/sentry/src/test/java/io/sentry/EventSizeLimitingUtilsTest.kt new file mode 100644 index 0000000000..d0efd85583 --- /dev/null +++ b/sentry/src/test/java/io/sentry/EventSizeLimitingUtilsTest.kt @@ -0,0 +1,460 @@ +package io.sentry + +import io.sentry.protocol.Message +import io.sentry.protocol.SentryException +import io.sentry.protocol.SentryStackFrame +import io.sentry.protocol.SentryStackTrace +import io.sentry.util.EventSizeLimitingUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EventSizeLimitingUtilsTest { + class Fixture { + fun getOptions(): SentryOptions { + val options = SentryOptions() + options.isEnableEventSizeLimiting = true + return options + } + } + + var fixture = Fixture() + + @Test + fun `does not modify event if size is below limit`() { + val options = fixture.getOptions() + val event = SentryEvent() + val message = Message() + message.message = "test message" + event.setMessage(message) + event.setExtra("key", "value") + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + assertEquals(event.getMessage(), result.getMessage()) + assertEquals(event.getExtras(), result.getExtras()) + } + + @Test + fun `removes all breadcrumbs when event exceeds size limit`() { + val options = fixture.getOptions() + val event = createLargeEvent() + + // Add many breadcrumbs with large data to exceed 1MB limit + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) // 15KB per breadcrumb + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + // All breadcrumbs should be removed + assertNull(result.getBreadcrumbs()) + } + + @Test + fun `truncates stack frames when event exceeds size limit`() { + val options = fixture.getOptions() + val event = createLargeEvent() + + // Add exception with large stack trace + val exception = SentryException() + exception.setType("RuntimeException") + exception.setValue("Test exception") + val stacktrace = SentryStackTrace() + val frames = mutableListOf() + for (i in 0..200) { + val frame = SentryStackFrame() + frame.setModule("com.example.Class$i") + frame.setFunction("method$i") + frame.setFilename("File$i.java") + frame.setLineno(i) + frames.add(frame) + } + stacktrace.setFrames(frames) + exception.setStacktrace(stacktrace) + event.setExceptions(listOf(exception)) + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + val resultExceptions = result.getExceptions() + assertNotNull(resultExceptions) + assertTrue(resultExceptions!!.isNotEmpty()) + val resultStacktrace = resultExceptions[0].getStacktrace() + assertNotNull(resultStacktrace) + val resultFrames = resultStacktrace.getFrames() + assertNotNull(resultFrames) + // Should be truncated to 500 frames (250 from start + 250 from end) when over 500 + // For 200 frames, no truncation should occur since it's less than 500 + assertTrue(resultFrames!!.size <= 500) + } + + @Test + fun `truncates stack frames when event has more than 500 frames`() { + val options = fixture.getOptions() + val event = createLargeEvent() + + // Add exception with very large stack trace (> 500 frames) + val exception = SentryException() + exception.setType("RuntimeException") + exception.setValue("Test exception") + val stacktrace = SentryStackTrace() + val frames = mutableListOf() + // Create 601 frames (0..600) with large data to ensure event exceeds size limit + for (i in 0..600) { + val frame = SentryStackFrame() + frame.setModule("com.example.Class$i") + frame.setFunction("method$i" + "x".repeat(1024)) // Large function name + frame.setFilename("File$i.java") + frame.setLineno(i) + frame.setContextLine("x".repeat(2048)) // Large context line + frames.add(frame) + } + stacktrace.setFrames(frames) + exception.setStacktrace(stacktrace) + event.setExceptions(listOf(exception)) + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + val resultExceptions = result.getExceptions() + assertNotNull(resultExceptions) + assertTrue(resultExceptions!!.isNotEmpty()) + val resultStacktrace = resultExceptions[0].getStacktrace() + assertNotNull(resultStacktrace) + val resultFrames = resultStacktrace.getFrames() + assertNotNull(resultFrames) + // Should be truncated to 500 frames (250 from start + 250 from end) + assertEquals(500, resultFrames!!.size) + } + + @Test + fun `invokes onOversizedEvent callback when event exceeds size limit`() { + val options = fixture.getOptions() + var callbackInvoked = false + var receivedEvent: SentryEvent? = null + var receivedHint: Hint? = null + options.setOnOversizedEvent { event, hint -> + callbackInvoked = true + receivedEvent = event + receivedHint = hint + event + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val hint = Hint() + val result = EventSizeLimitingUtils.limitEventSize(event, hint, options) + + assertTrue(callbackInvoked) + assertNotNull(receivedEvent) + assertEquals(event, receivedEvent) + assertEquals(hint, receivedHint) + assertNotNull(result) + } + + @Test + fun `onOversizedEvent callback successfully reduces size below limit`() { + val options = fixture.getOptions() + options.setOnOversizedEvent { event, _ -> + // Remove all breadcrumbs to reduce size + event.setBreadcrumbs(null) + event + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + // Breadcrumbs should be removed by callback + assertNull(result.getBreadcrumbs()) + // No further reduction should be needed + } + + @Test + fun `onOversizedEvent callback insufficient reduction continues with automatic steps`() { + val options = fixture.getOptions() + var callbackInvoked = false + options.setOnOversizedEvent { event, _ -> + callbackInvoked = true + // Remove only some breadcrumbs, not enough to reduce size below limit + val breadcrumbs = event.getBreadcrumbs() + if (breadcrumbs != null && breadcrumbs.size > 20) { + // Keep only 20 breadcrumbs, but each is 15KB, so total is still ~300KB + // Add more data to ensure it's still oversized + val keptBreadcrumbs = breadcrumbs.subList(breadcrumbs.size - 20, breadcrumbs.size) + event.setBreadcrumbs(keptBreadcrumbs) + // Add extra data to ensure event is still oversized + event.setExtra("still_large", "x".repeat(800 * 1024)) // 800KB extra + } + event + } + val event = createLargeEvent() + + // Add many breadcrumbs with large data to exceed 1MB limit + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertTrue(callbackInvoked) + assertNotNull(result) + // Automatic reduction should remove all remaining breadcrumbs + assertNull(result.getBreadcrumbs()) + } + + @Test + fun `onOversizedEvent callback exception continues with automatic reduction`() { + val options = fixture.getOptions() + options.setOnOversizedEvent { _, _ -> throw RuntimeException("Callback error") } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + // Automatic reduction should still work despite callback exception + assertNull(result.getBreadcrumbs()) + } + + @Test + fun `onOversizedEvent callback not invoked when event is below size limit`() { + val options = fixture.getOptions() + var callbackInvoked = false + options.setOnOversizedEvent { _, _ -> + callbackInvoked = true + SentryEvent() + } + val event = SentryEvent() + val message = Message() + message.message = "test message" + event.setMessage(message) + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertFalse(callbackInvoked) + assertNotNull(result) + } + + @Test + fun `onOversizedEvent callback not invoked when event size limiting is disabled`() { + val options = SentryOptions() + options.isEnableEventSizeLimiting = false + var callbackInvoked = false + options.setOnOversizedEvent { _, _ -> + callbackInvoked = true + SentryEvent() + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertFalse(callbackInvoked) + assertNotNull(result) + // Event should be unchanged when limiting is disabled + assertNotNull(result.getBreadcrumbs()) + } + + @Test + fun `onOversizedEvent callback can replace event with a different event`() { + val options = fixture.getOptions() + val replacementEvent = SentryEvent() + val replacementMessage = Message() + replacementMessage.message = "Replacement event" + replacementEvent.setMessage(replacementMessage) + var callbackInvoked = false + options.setOnOversizedEvent { _, _ -> + callbackInvoked = true + replacementEvent // Return a completely different event + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertTrue(callbackInvoked) + assertNotNull(result) + assertEquals("Replacement event", result!!.getMessage()?.message) + } + + @Test + fun `onOversizedEvent callback returning same event unchanged continues with automatic reduction`() { + val options = fixture.getOptions() + var callbackInvoked = false + options.setOnOversizedEvent { event, _ -> + callbackInvoked = true + event // Return the same event without modifications + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertTrue(callbackInvoked) + assertNotNull(result) + // Automatic reduction should have removed breadcrumbs + assertNull(result.getBreadcrumbs()) + } + + @Test + fun `onOversizedEvent callback receives correct hint object`() { + val options = fixture.getOptions() + var receivedHint: Hint? = null + options.setOnOversizedEvent { event, hint -> + receivedHint = hint + event + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val hint = Hint() + hint.set("custom_key", "custom_value") + val result = EventSizeLimitingUtils.limitEventSize(event, hint, options) + + assertNotNull(result) + assertNotNull(receivedHint) + assertEquals("custom_value", receivedHint!!.get("custom_key")) + } + + @Test + fun `onOversizedEvent callback can modify extras to reduce size`() { + val options = fixture.getOptions() + options.setOnOversizedEvent { event, _ -> + // Remove extras to reduce size + event.setExtras(null) + event + } + val event = createLargeEvent() + + // Add large extras + for (i in 0..100) { + event.setExtra("large_extra_$i", "x".repeat(15 * 1024)) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + // Extras should be removed by callback + assertNull(result.getExtras()) + } + + @Test + fun `onOversizedEvent callback can modify contexts to reduce size`() { + val options = fixture.getOptions() + options.setOnOversizedEvent { event, _ -> + // Remove contexts to reduce size + event.contexts.keys().toList().forEach { event.contexts.remove(it) } + event + } + val event = createLargeEvent() + + // Add large contexts + for (i in 0..100) { + val context = mutableMapOf() + context["data"] = "x".repeat(15 * 1024) + event.contexts.set("context_$i", context) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + // Contexts should be removed by callback + assertEquals(0, result.contexts.size) + } + + @Test + fun `onOversizedEvent callback multiple invocations not expected`() { + val options = fixture.getOptions() + var callbackInvocationCount = 0 + options.setOnOversizedEvent { event, _ -> + callbackInvocationCount++ + event + } + val event = createLargeEvent() + + // Add breadcrumbs to make it large + for (i in 0..100) { + val breadcrumb = Breadcrumb() + breadcrumb.message = "breadcrumb $i" + breadcrumb.setData("large_data", "x".repeat(15 * 1024)) + event.addBreadcrumb(breadcrumb) + } + + val result = EventSizeLimitingUtils.limitEventSize(event, Hint(), options) + + assertNotNull(result) + // Callback should be invoked exactly once + assertEquals(1, callbackInvocationCount) + } + + private fun createLargeEvent(): SentryEvent { + val event = SentryEvent() + val message = Message() + message.message = "Large event for testing" + event.setMessage(message) + return event + } +}