Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 getOnOversizedError ()Lio/sentry/SentryOptions$OnOversizedErrorCallback;
public fun getOpenTelemetryMode ()Lio/sentry/SentryOpenTelemetryMode;
public fun getOptionsObservers ()Ljava/util/List;
public fun getOutboxPath ()Ljava/lang/String;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 setOnOversizedError (Lio/sentry/SentryOptions$OnOversizedErrorCallback;)V
public fun setOpenTelemetryMode (Lio/sentry/SentryOpenTelemetryMode;)V
public fun setPrintUncaughtStackTrace (Z)V
public fun setProfileLifecycle (Lio/sentry/ProfileLifecycle;)V
Expand Down Expand Up @@ -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$OnOversizedErrorCallback {
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;
}
Expand Down Expand Up @@ -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 <init> ()V
public static fun findRootCause (Ljava/lang/Throwable;)Ljava/lang/Throwable;
Expand Down
4 changes: 4 additions & 0 deletions sentry/src/main/java/io/sentry/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
65 changes: 65 additions & 0 deletions sentry/src/main/java/io/sentry/SentryOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kepping this opt-in for now, we could turn it on by default in the next major.


/**
* Callback invoked when an oversized event is detected. This allows custom handling of oversized
* events before the automatic reduction steps are applied.
*/
private @Nullable OnOversizedErrorCallback onOversizedError;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we best call this so customers know what it's used for?
Should this instead be more like an EventProcessor interface, where we can add methods for each feature (error events, spans, ...) in the future?

Suggested change
private @Nullable OnOversizedErrorCallback onOversizedError;
private @Nullable OnOversizedEventCallback onOversizedEvent;


/** Maximum number of spans that can be atteched to single transaction. */
private int maxSpans = 1000;

Expand Down Expand Up @@ -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 onOversizedError callback.
*
* @return the onOversizedError callback or null if not set
*/
public @Nullable OnOversizedErrorCallback getOnOversizedError() {
return onOversizedError;
}

/**
* Sets the onOversizedError callback. This callback is invoked when an oversized event is
* detected, before the automatic reduction steps are applied.
*
* @param onOversizedError the onOversizedError callback
*/
public void setOnOversizedError(@Nullable OnOversizedErrorCallback onOversizedError) {
this.onOversizedError = onOversizedError;
}

/**
* Returns if tracing should be enabled. If tracing is disabled, starting transactions returns
* {@link NoOpTransaction}.
Expand Down Expand Up @@ -3136,6 +3186,21 @@ public interface BeforeBreadcrumbCallback {
Breadcrumb execute(@NotNull Breadcrumb breadcrumb, @NotNull Hint hint);
}

/** The OnOversizedError callback */
public interface OnOversizedErrorCallback {

/**
* 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);
Copy link
Member Author

@adinauer adinauer Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO running this between beforeSend and passing this on to the transport is the best place to check since customers might be adding large amounts of data in beforeSend, so first reducing size and then adding back to it could easily cause the event to become too large again.

}

/** The OnDiscard callback */
public interface OnDiscardCallback {

Expand Down
198 changes: 198 additions & 0 deletions sentry/src/main/java/io/sentry/util/EventSizeLimitingUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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. This runs after beforeSend and right before sending the event.
*
* <p>Fields are reduced in order of least importance:
*
* <ol>
* <li>All breadcrumbs
* <li>Exception stack frames (keep 250 frames from start and 250 frames from end, removing
* middle)
* </ol>
*
* <p>Note: Extras, tags, threads, request data, debug meta, and contexts are preserved.
*/
@ApiStatus.Internal
public final class EventSizeLimitingUtils {

private static final long MAX_EVENT_SIZE_BYTES = 1024 * 1024; // 1MB
private static final int FRAMES_PER_SIDE = 250; // Keep 250 frames from start and 250 from end

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) {
if (!options.isEnableEventSizeLimiting()) {
return event;
}

if (!isTooLarge(event, options)) {
return event;
}

long eventSize = byteSizeOf(event, options);
options
.getLogger()
.log(
SentryLevel.INFO,
"Event size (%d bytes) exceeds %d bytes limit. Reducing size by dropping fields.",
eventSize,
MAX_EVENT_SIZE_BYTES);

SentryEvent reducedEvent = event;

// Step 0: Invoke custom callback if defined
final SentryOptions.OnOversizedErrorCallback onOversizedError = options.getOnOversizedError();
if (onOversizedError != null) {
try {
reducedEvent = onOversizedError.execute(reducedEvent, hint);
if (!isTooLarge(reducedEvent, options)) {
return reducedEvent;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Misinterpreted Null Leads to Silent Data Loss

When onOversizedError callback returns null (violating its @NotNull contract), the code treats this as success because byteSizeOf(null) returns 0, making isTooLarge return false. This causes the event to be silently dropped without error logging, instead of continuing with automatic reduction. The callback result needs validation before the size check.

Fix in Cursor Fix in Web

} catch (Exception e) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Callback Errors: A Crash Hazard

The callback exception handler catches Exception instead of Throwable, inconsistent with other callback handlers in the codebase like executeBeforeSend. This means Error subclasses (like OutOfMemoryError) thrown by the callback won't be caught, potentially crashing the SDK instead of continuing with automatic reduction.

Fix in Cursor Fix in Web

options
.getLogger()
.log(
SentryLevel.ERROR,
"The onOversizedError callback threw an exception. It will be ignored and automatic reduction will continue.",
e);
// Continue with automatic reduction if callback fails
reducedEvent = event;
}
}

// Step 1: Remove all breadcrumbs
reducedEvent = removeAllBreadcrumbs(reducedEvent, options);
if (!isTooLarge(reducedEvent, options)) {
return reducedEvent;
}

// Step 2: Truncate stack frames (keep 250 from start and 250 from end)
reducedEvent = truncateStackFrames(reducedEvent, options);
if (isTooLarge(reducedEvent, options)) {
long finalEventSize = byteSizeOf(reducedEvent, options);
options
.getLogger()
.log(
SentryLevel.WARNING,
"Event size (%d bytes) still exceeds limit after reducing all fields. Event may be rejected by server.",
finalEventSize);
}

return reducedEvent;
}

/**
* Checks if the event exceeds the size limit.
*
* @param event the event to check
* @param options the SentryOptions
* @return true if the event exceeds the size limit
*/
private static boolean isTooLarge(
final @NotNull SentryEvent event, final @NotNull SentryOptions options) {
return byteSizeOf(event, options) > MAX_EVENT_SIZE_BYTES;
}

/** Calculates the size of the event when serialized to JSON without actually storing the data. */
private static long byteSizeOf(
final @NotNull SentryEvent event, final @NotNull SentryOptions options) {
return JsonSerializationUtils.byteSizeOf(options.getSerializer(), options.getLogger(), event);
}

private static @NotNull SentryEvent removeAllBreadcrumbs(
final @NotNull SentryEvent event, final @NotNull SentryOptions options) {
final List<Breadcrumb> breadcrumbs = event.getBreadcrumbs();
if (breadcrumbs != null && !breadcrumbs.isEmpty()) {
event.setBreadcrumbs(null);
options
.getLogger()
.log(
SentryLevel.DEBUG, "Removed %d breadcrumbs to reduce event size", breadcrumbs.size());
}
return event;
}

private static @NotNull SentryEvent truncateStackFrames(
final @NotNull SentryEvent event, final @NotNull SentryOptions options) {
final List<SentryException> exceptions = event.getExceptions();
if (exceptions != null) {
for (final SentryException exception : exceptions) {
final SentryStackTrace stacktrace = exception.getStacktrace();
if (stacktrace != null) {
final List<SentryStackFrame> frames = stacktrace.getFrames();
if (frames != null && frames.size() > FRAMES_PER_SIDE * 2) {
// Keep first 250 frames and last 250 frames, removing middle
final List<SentryStackFrame> 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 stack frames from %d to %d (removed middle) for exception %s",
frames.size(),
truncatedFrames.size(),
exception.getType());
}
}
}
}

// Also truncate thread stack traces
final List<SentryThread> threads = event.getThreads();
if (threads != null) {
for (final SentryThread thread : threads) {
final SentryStackTrace stacktrace = thread.getStacktrace();
if (stacktrace != null) {
final List<SentryStackFrame> frames = stacktrace.getFrames();
if (frames != null && frames.size() > FRAMES_PER_SIDE * 2) {
// Keep first 250 frames and last 250 frames, removing middle
final List<SentryStackFrame> 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 stack frames from %d to %d (removed middle) for thread %d",
frames.size(),
truncatedFrames.size(),
thread.getId());
}
}
}
}

return event;
}
}
Loading
Loading