Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
- Discard envelopes on `4xx` and `5xx` response ([#4950](https://github.com/getsentry/sentry-java/pull/4950))
- This aims to not overwhelm Sentry after an outage or load shedding (including HTTP 429) where too many events are sent at once

### Features

- Add new experimental option to capture profiles for ANRs ([#4899](https://github.com/getsentry/sentry-java/pull/4899))
- This feature will capture a stack profile of the main thread when it gets unresponsive
- The profile gets attached to the ANR event on the next app start, providing a flamegraph of the ANR issue on the sentry issue details page
- Breaking change: if the ANR stacktrace contains only system frames (e.g. `java.lang` or `android.os`), a static fingerprint is set on the ANR event, causing all ANR events to be grouped into a single issue, reducing the overall ANR issue noise.
- Enable via `options.setEnableAnrProfiling(true)` or Android manifest: `<meta-data android:name="io.sentry.anr.enable-profiling" android:value="true" />`

## 8.29.0

### Fixes
Expand Down
80 changes: 80 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
public fun isCollectExternalStorageContext ()Z
public fun isEnableActivityLifecycleBreadcrumbs ()Z
public fun isEnableActivityLifecycleTracingAutoFinish ()Z
public fun isEnableAnrProfiling ()Z
public fun isEnableAppComponentBreadcrumbs ()Z
public fun isEnableAppLifecycleBreadcrumbs ()Z
public fun isEnableAutoActivityLifecycleTracing ()Z
Expand All @@ -365,6 +366,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
public fun setDebugImagesLoader (Lio/sentry/android/core/IDebugImagesLoader;)V
public fun setEnableActivityLifecycleBreadcrumbs (Z)V
public fun setEnableActivityLifecycleTracingAutoFinish (Z)V
public fun setEnableAnrProfiling (Z)V
public fun setEnableAppComponentBreadcrumbs (Z)V
public fun setEnableAppLifecycleBreadcrumbs (Z)V
public fun setEnableAutoActivityLifecycleTracing (Z)V
Expand Down Expand Up @@ -494,6 +496,84 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr
public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B
}

public class io/sentry/android/core/anr/AggregatedStackTrace {
public fun <init> ([Ljava/lang/StackTraceElement;IIJF)V
public fun addOccurrence (J)V
public fun getStack ()[Ljava/lang/StackTraceElement;
}

public class io/sentry/android/core/anr/AnrCulpritIdentifier {
public fun <init> ()V
public static fun identify (Ljava/util/List;)Lio/sentry/android/core/anr/AggregatedStackTrace;
public static fun isSystemFrame (Ljava/lang/String;)Z
}

public class io/sentry/android/core/anr/AnrException : java/lang/Exception {
public fun <init> ()V
public fun <init> (Ljava/lang/String;)V
}

public class io/sentry/android/core/anr/AnrProfile {
public final field endtimeMs J
public final field stacks Ljava/util/List;
public final field startTimeMs J
public fun <init> (Ljava/util/List;)V
}

public class io/sentry/android/core/anr/AnrProfileManager : java/io/Closeable {
public fun <init> (Lio/sentry/SentryOptions;)V
public fun <init> (Lio/sentry/SentryOptions;Ljava/io/File;)V
public fun add (Lio/sentry/android/core/anr/AnrStackTrace;)V
public fun clear ()V
public fun close ()V
public fun load ()Lio/sentry/android/core/anr/AnrProfile;
}

public class io/sentry/android/core/anr/AnrProfileRotationHelper {
public fun <init> ()V
public static fun deleteLastFile (Ljava/io/File;)Z
public static fun getFileForRecording (Ljava/io/File;)Ljava/io/File;
public static fun getLastFile (Ljava/io/File;)Ljava/io/File;
public static fun rotate ()V
}

public class io/sentry/android/core/anr/AnrProfilingIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable, java/lang/Runnable {
public static final field POLLING_INTERVAL_MS J
public static final field THRESHOLD_ANR_MS J
public fun <init> ()V
protected fun checkMainThread (Ljava/lang/Thread;)V
public fun close ()V
protected fun getProfileManager ()Lio/sentry/android/core/anr/AnrProfileManager;
protected fun getState ()Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState;
public fun onBackground ()V
public fun onForeground ()V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
public fun run ()V
}

protected final class io/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState : java/lang/Enum {
public static final field ANR_DETECTED Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState;
public static final field IDLE Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState;
public static final field SUSPICIOUS Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState;
public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState;
public static fun values ()[Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState;
}

public final class io/sentry/android/core/anr/AnrStackTrace : java/lang/Comparable {
public final field stack [Ljava/lang/StackTraceElement;
public final field timestampMs J
public fun <init> (J[Ljava/lang/StackTraceElement;)V
public fun compareTo (Lio/sentry/android/core/anr/AnrStackTrace;)I
public synthetic fun compareTo (Ljava/lang/Object;)I
public static fun deserialize (Ljava/io/DataInputStream;)Lio/sentry/android/core/anr/AnrStackTrace;
public fun serialize (Ljava/io/DataOutputStream;)V
}

public final class io/sentry/android/core/anr/StackTraceConverter {
public fun <init> ()V
public static fun convert (Lio/sentry/android/core/anr/AnrProfile;)Lio/sentry/protocol/profiling/SentryProfile;
}

public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache {
public static final field LAST_ANR_REPORT Ljava/lang/String;
public fun <init> (Lio/sentry/android/core/SentryAndroidOptions;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import io.sentry.SendFireAndForgetOutboxSender;
import io.sentry.SentryLevel;
import io.sentry.SentryOpenTelemetryMode;
import io.sentry.android.core.anr.AnrProfileRotationHelper;
import io.sentry.android.core.anr.AnrProfilingIntegration;
import io.sentry.android.core.cache.AndroidEnvelopeCache;
import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader;
import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator;
Expand Down Expand Up @@ -138,6 +140,8 @@ static void loadDefaultAndMetadataOptions(
.getRuntimeManager()
.runWithRelaxedPolicy(() -> getCacheDir(finalContext).getAbsolutePath()));

AnrProfileRotationHelper.rotate();

readDefaultOptionValues(options, finalContext, buildInfoProvider);
AppState.getInstance().registerLifecycleObserver(options);
}
Expand Down Expand Up @@ -392,6 +396,8 @@ static void installDefaultIntegrations(
// it to set the replayId in case of an ANR
options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider));

options.addIntegration(new AnrProfilingIntegration());

// registerActivityLifecycleCallbacks is only available if Context is an AppContext
if (context instanceof Application) {
options.addIntegration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,20 @@
import io.sentry.ILogger;
import io.sentry.IScopes;
import io.sentry.Integration;
import io.sentry.ProfileChunk;
import io.sentry.ProfileContext;
import io.sentry.SentryEvent;
import io.sentry.SentryExceptionFactory;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.SentryStackTraceFactory;
import io.sentry.android.core.anr.AggregatedStackTrace;
import io.sentry.android.core.anr.AnrCulpritIdentifier;
import io.sentry.android.core.anr.AnrException;
import io.sentry.android.core.anr.AnrProfile;
import io.sentry.android.core.anr.AnrProfileManager;
import io.sentry.android.core.anr.AnrProfileRotationHelper;
import io.sentry.android.core.anr.StackTraceConverter;
import io.sentry.android.core.cache.AndroidEnvelopeCache;
import io.sentry.android.core.internal.threaddump.Lines;
import io.sentry.android.core.internal.threaddump.ThreadDumpParser;
Expand All @@ -26,8 +37,12 @@
import io.sentry.protocol.DebugImage;
import io.sentry.protocol.DebugMeta;
import io.sentry.protocol.Message;
import io.sentry.protocol.SentryException;
import io.sentry.protocol.SentryId;
import io.sentry.protocol.SentryStackFrame;
import io.sentry.protocol.SentryStackTrace;
import io.sentry.protocol.SentryThread;
import io.sentry.protocol.profiling.SentryProfile;
import io.sentry.transport.CurrentDateProvider;
import io.sentry.transport.ICurrentDateProvider;
import io.sentry.util.HintUtils;
Expand All @@ -36,11 +51,14 @@
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.jetbrains.annotations.ApiStatus;
Expand Down Expand Up @@ -284,6 +302,20 @@ private void reportAsSentryEvent(
}
}

if (options.isEnableAnrProfiling()) {
applyAnrProfile(isBackground, anrTimestamp, event);
// TODO: maybe move to AnrV2EventProcessor instead
if (hasOnlySystemFrames(event)) {
// By omitting the {{ default }} fingerprint, the stacktrace will be completely ignored
// and all events will be grouped
// into the same issue
event.setFingerprints(
Arrays.asList(
"{{ system-frames-only-anr }}",
isBackground ? "background-anr" : "foreground-anr"));
}
}

final @NotNull SentryId sentryId = scopes.captureEvent(event, hint);
final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID);
if (!isEventDropped) {
Comment on lines +311 to 321
Copy link

Choose a reason for hiding this comment

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

Bug: When ANR profiling fails, hasOnlySystemFrames() incorrectly returns true because no exceptions are added, causing a "system-frames-only-anr" fingerprint to be applied to events with unverified frames.
Severity: HIGH | Confidence: High

🔍 Detailed Analysis

When ANR profiling is enabled but fails to apply a profile for any reason (e.g., missing file, timestamp mismatch), the event will have no exceptions. The hasOnlySystemFrames() method incorrectly returns true when the exception list is null. As a result, a special "system-frames-only-anr" fingerprint is applied to the event, even though it contains a valid thread dump with potentially non-system frames. This leads to incorrect event grouping, merging diverse ANRs into a single issue and obscuring their true root causes when profiling fails.

💡 Suggested Fix

Modify the logic to only apply the "system-frames-only-anr" fingerprint when the ANR profile has been successfully applied and exceptions have been added to the event. The hasOnlySystemFrames() method should return false if the exception list is null or empty, ensuring the fingerprint is only applied after frames have been verified.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java#L302-L321

Potential issue: When ANR profiling is enabled but fails to apply a profile for any
reason (e.g., missing file, timestamp mismatch), the event will have no exceptions. The
`hasOnlySystemFrames()` method incorrectly returns `true` when the exception list is
`null`. As a result, a special `"system-frames-only-anr"` fingerprint is applied to the
event, even though it contains a valid thread dump with potentially non-system frames.
This leads to incorrect event grouping, merging diverse ANRs into a single issue and
obscuring their true root causes when profiling fails.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7690328

Expand All @@ -299,6 +331,94 @@ private void reportAsSentryEvent(
}
}

private void applyAnrProfile(
final boolean isBackground, final long anrTimestamp, final @NotNull SentryEvent event) {

// as of now AnrProfilingIntegration only generates profiles in foreground
if (isBackground) {
return;
}

final @Nullable String cacheDirPath = options.getCacheDirPath();
if (cacheDirPath == null) {
return;
}
final @NotNull File cacheDir = new File(cacheDirPath);

@Nullable AnrProfile anrProfile = null;

try {
final File lastFile = AnrProfileRotationHelper.getLastFile(cacheDir);

if (lastFile.exists()) {
options.getLogger().log(SentryLevel.DEBUG, "Reading ANR profile");
try (final AnrProfileManager provider = new AnrProfileManager(options, lastFile)) {
anrProfile = provider.load();
}
} else {
options.getLogger().log(SentryLevel.DEBUG, "No ANR profile file found");
}
} catch (Throwable t) {
options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile", t);
} finally {
if (!AnrProfileRotationHelper.deleteLastFile(cacheDir)) {
options.getLogger().log(SentryLevel.INFO, "Could not delete ANR profile file");
}
}

if (anrProfile != null) {
options.getLogger().log(SentryLevel.INFO, "ANR profile found");
if (anrTimestamp >= anrProfile.startTimeMs && anrTimestamp <= anrProfile.endtimeMs) {
final @Nullable AggregatedStackTrace culprit =
AnrCulpritIdentifier.identify(anrProfile.stacks);
if (culprit != null) {
final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile);

final @NotNull StackTraceElement[] stack = culprit.getStack();
if (stack.length > 0) {
final StackTraceElement stackTraceElement = culprit.getStack()[0];
final String message =
stackTraceElement.getClassName() + "." + stackTraceElement.getMethodName();
final AnrException exception = new AnrException(message);
exception.setStackTrace(stack);

// TODO should this be re-used from somewhere else?
final SentryExceptionFactory factory =
new SentryExceptionFactory(new SentryStackTraceFactory(options));
event.setExceptions(factory.getSentryExceptions(exception));
if (profilerId != null) {
event.getContexts().setProfile(new ProfileContext(profilerId));
}
}
}
} else {
options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match");
}
}
}

@Nullable
private SentryId captureAnrProfile(
final long anrTimestamp, final @NotNull AnrProfile anrProfile) {
final SentryProfile profile = StackTraceConverter.convert(anrProfile);
final ProfileChunk chunk =
new ProfileChunk(
new SentryId(),
new SentryId(),
null,
new HashMap<>(0),
anrTimestamp / 1000.0d,
ProfileChunk.PLATFORM_JAVA,
options);
chunk.setSentryProfile(profile);
final SentryId profilerId = scopes.captureProfileChunk(chunk);
if (profilerId.equals(SentryId.EMPTY_ID)) {
return null;
} else {
return chunk.getProfilerId();
}
}

private @NotNull ParseResult parseThreadDump(
final @NotNull ApplicationExitInfo exitInfo, final boolean isBackground) {
final byte[] dump;
Expand Down Expand Up @@ -352,6 +472,30 @@ private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException
}
}

private static boolean hasOnlySystemFrames(final @NotNull SentryEvent event) {
final List<SentryException> exceptions = event.getExceptions();
if (exceptions != null) {
for (final SentryException exception : exceptions) {
final @Nullable SentryStackTrace stacktrace = exception.getStacktrace();
if (stacktrace != null) {
final @Nullable List<SentryStackFrame> frames = stacktrace.getFrames();
if (frames != null && !frames.isEmpty()) {
for (final SentryStackFrame frame : frames) {
if (frame.isInApp() != null && frame.isInApp()) {
return false;
}
final @Nullable String module = frame.getModule();
if (module != null && !AnrCulpritIdentifier.isSystemFrame(frame.getModule())) {
return false;
}
}
}
}
}
}
return true;
}
Copy link

Choose a reason for hiding this comment

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

Bug: Fingerprinting applied when no exceptions exist

The hasOnlySystemFrames method returns true when the event has no exceptions (null), treating "no exceptions to check" the same as "only system frames present". When ANR profiling is enabled but no profile matches (e.g., profile file doesn't exist or timestamp mismatch), applyAnrProfile returns without setting exceptions, leaving only threads from the thread dump. The hasOnlySystemFrames check then returns true, causing the "system-frames-only" fingerprint to be applied to all ANR events without matching profiles, regardless of their actual stack content. This could incorrectly group unrelated ANRs into a single issue.

Additional Locations (1)

Fix in Cursor Fix in Web


@ApiStatus.Internal
public static final class AnrV2Hint extends BlockingFlushHint
implements Backfillable, AbnormalExit {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ final class ManifestMetadataReader {

static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding";

static final String ENABLE_ANR_PROFILING = "io.sentry.anr.enable-profiling";

/** ManifestMetadataReader ctor */
private ManifestMetadataReader() {}

Expand Down Expand Up @@ -628,6 +630,9 @@ static void applyMetadata(
metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser()));
feedbackOptions.setShowBranding(
readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding()));

options.setEnableAnrProfiling(
readBool(metadata, logger, ENABLE_ANR_PROFILING, options.isEnableAnrProfiling()));
}
options
.getLogger()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ public interface BeforeCaptureCallback {

private @Nullable SentryFrameMetricsCollector frameMetricsCollector;

private boolean enableAnrProfiling = false;

public SentryAndroidOptions() {
setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME);
setSdkVersion(createSdkVersion());
Expand Down Expand Up @@ -637,6 +639,14 @@ public void setEnableSystemEventBreadcrumbsExtras(
this.enableSystemEventBreadcrumbsExtras = enableSystemEventBreadcrumbsExtras;
}

public boolean isEnableAnrProfiling() {
return enableAnrProfiling;
}

public void setEnableAnrProfiling(final boolean enableAnrProfiling) {
this.enableAnrProfiling = enableAnrProfiling;
}

static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler {
@Override
public void showDialog(
Expand Down
Loading
Loading