From b286ad526052f47f8a13a0584082c69a1f72de59 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 12 Nov 2025 20:17:12 +0100 Subject: [PATCH 01/23] Profile main thread when ANR and report ANR profiles to sentry --- .../api/sentry-android-core.api | 58 +++++ .../core/AndroidOptionsInitializer.java | 5 + .../sentry/android/core/AnrV2Integration.java | 75 +++++++ .../android/core/ManifestMetadataReader.java | 5 + .../android/core/SentryAndroidOptions.java | 10 + .../core/anr/AggregatedStackTrace.java | 54 +++++ .../core/anr/AnrCulpritIdentifier.java | 90 ++++++++ .../sentry/android/core/anr/AnrException.java | 15 ++ .../sentry/android/core/anr/AnrProfile.java | 32 +++ .../android/core/anr/AnrProfileManager.java | 88 ++++++++ .../core/anr/AnrProfilingIntegration.java | 195 ++++++++++++++++ .../android/core/anr/AnrStackTrace.java | 68 ++++++ .../android/core/anr/StackTraceConverter.java | 150 +++++++++++++ .../core/ManifestMetadataReaderTest.kt | 25 +++ .../android/core/SentryAndroidOptionsTest.kt | 22 ++ .../core/anr/AnrCulpritIdentifierTest.kt | 147 ++++++++++++ .../android/core/anr/AnrProfileManagerTest.kt | 145 ++++++++++++ .../core/anr/AnrProfilingIntegrationTest.kt | 194 ++++++++++++++++ .../core/anr/AnrStackTraceConverterTest.kt | 209 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 2 + sentry/api/sentry.api | 1 + .../src/main/java/io/sentry/ProfileChunk.java | 8 +- .../java/io/sentry/SentryEnvelopeItem.java | 64 +++--- .../main/java/io/sentry/util/StringUtils.java | 8 + 24 files changed, 1636 insertions(+), 34 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 1c89d8524c0..63c1ffec606 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -326,6 +326,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isCollectAdditionalContext ()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 @@ -351,6 +352,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 @@ -480,6 +482,62 @@ 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 ([Ljava/lang/StackTraceElement;IIJI)V + public fun add (J)V + public fun getStack ()[Ljava/lang/StackTraceElement; +} + +public class io/sentry/android/core/anr/AnrCulpritIdentifier { + public fun ()V + public static fun identify (Ljava/util/List;)Lio/sentry/android/core/anr/AggregatedStackTrace; +} + +public class io/sentry/android/core/anr/AnrException : java/lang/Exception { + public fun ()V + public fun (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 (Ljava/util/List;)V +} + +public class io/sentry/android/core/anr/AnrProfileManager { + public fun (Lio/sentry/SentryOptions;)V + public fun add (Lio/sentry/android/core/anr/AnrStackTrace;)V + public fun clear ()V + public fun load ()Lio/sentry/android/core/anr/AnrProfile; +} + +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 ()V + public fun close ()V + public fun onBackground ()V + public fun onForeground ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun run ()V +} + +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 (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 ()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 (Lio/sentry/android/core/SentryAndroidOptions;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 4e679a22e96..0dc8c486ccb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -25,6 +25,7 @@ import io.sentry.SendFireAndForgetOutboxSender; import io.sentry.SentryLevel; import io.sentry.SentryOpenTelemetryMode; +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; @@ -391,6 +392,10 @@ static void installDefaultIntegrations( // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); + if (options.isEnableAnrProfiling()) { + options.addIntegration(new AnrProfilingIntegration()); + } + // registerActivityLifecycleCallbacks is only available if Context is an AppContext if (context instanceof Application) { options.addIntegration( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index c6d47cadcb4..562cf394fbe 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -12,9 +12,19 @@ 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.StackTraceConverter; import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.threaddump.Lines; import io.sentry.android.core.internal.threaddump.ThreadDumpParser; @@ -28,6 +38,7 @@ import io.sentry.protocol.Message; import io.sentry.protocol.SentryId; 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; @@ -41,6 +52,7 @@ import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; @@ -284,6 +296,8 @@ private void reportAsSentryEvent( } } + applyAnrProfile(isBackground, anrTimestamp, event); + final @NotNull SentryId sentryId = scopes.captureEvent(event, hint); final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); if (!isEventDropped) { @@ -299,6 +313,67 @@ 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; + } + + @Nullable AnrProfile anrProfile = null; + try { + final AnrProfileManager provider = new AnrProfileManager(options); + anrProfile = provider.load(); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile"); + } + + if (anrProfile != null) { + options.getLogger().log(SentryLevel.INFO, "ANR profile found"); + // TODO maybe be less strict around the end timestamp + if (anrTimestamp >= anrProfile.startTimeMs && anrTimestamp <= anrProfile.endtimeMs) { + 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); + + options.getLogger().log(SentryLevel.DEBUG, ""); + scopes.captureProfileChunk(chunk); + + final @Nullable AggregatedStackTrace culprit = + AnrCulpritIdentifier.identify(anrProfile.stacks); + if (culprit != null) { + // TODO if quality is low (e.g. when culprit is pollNative()) + // consider throwing the ANR using a static fingerprint to reduce noise + 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)); + event.getContexts().setProfile(new ProfileContext(chunk.getProfilerId())); + } + } + } else { + options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match"); + } + } + } + private @NotNull ParseResult parseThreadDump( final @NotNull ApplicationExitInfo exitInfo, final boolean isBackground) { final byte[] dump; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index a71ec1cb764..b3a7dce6a93 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -143,6 +143,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() {} @@ -522,6 +524,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() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 221495172eb..79f980c7f65 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -227,6 +227,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()); @@ -626,6 +628,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( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java new file mode 100644 index 00000000000..e351c694ce6 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java @@ -0,0 +1,54 @@ +package io.sentry.android.core.anr; + +import java.util.Arrays; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class AggregatedStackTrace { + // the number of frames of the stacktrace + final int depth; + + // the quality of the stack trace, higher means better + final int quality; + + private final StackTraceElement[] stack; + + // 0 is the most detailed frame in the stacktrace + private final int stackStartIdx; + private final int stackEndIdx; + + // the total number of times this exact stacktrace was captured + int count; + + // first time the stacktrace occured + private long startTimeMs; + + // last time the stacktrace occured + private long endTimeMs; + + public AggregatedStackTrace( + final StackTraceElement[] stack, + final int stackStartIdx, + final int stackEndIdx, + final long timestampMs, + final int quality) { + this.stack = stack; + this.stackStartIdx = stackStartIdx; + this.stackEndIdx = stackEndIdx; + this.depth = stackEndIdx - stackStartIdx; + this.startTimeMs = timestampMs; + this.endTimeMs = timestampMs; + this.count = 1; + this.quality = quality; + } + + public void add(long timestampMs) { + this.startTimeMs = Math.min(startTimeMs, timestampMs); + this.endTimeMs = Math.max(endTimeMs, timestampMs); + this.count++; + } + + public StackTraceElement[] getStack() { + return Arrays.copyOfRange(stack, stackStartIdx, stackEndIdx + 1); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java new file mode 100644 index 00000000000..37af40887a0 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -0,0 +1,90 @@ +package io.sentry.android.core.anr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class AnrCulpritIdentifier { + + // common Java and Android packages who are less relevant for being the actual culprit + private static final List lowQualityPackages = new ArrayList<>(9); + + { + lowQualityPackages.add("java.lang"); + lowQualityPackages.add("java.util"); + lowQualityPackages.add("android.app"); + lowQualityPackages.add("android.os.Handler"); + lowQualityPackages.add("android.os.Looper"); + lowQualityPackages.add("android.view"); + lowQualityPackages.add("android.widget"); + lowQualityPackages.add("com.android.internal"); + lowQualityPackages.add("com.google.android"); + } + + /** + * @param dumps + * @return + */ + @Nullable + public static AggregatedStackTrace identify(final @NotNull List dumps) { + if (dumps.isEmpty()) { + return null; + } + + // fold all stacktraces and count their occurrences + final Map stackTraceMap = new HashMap<>(); + for (final AnrStackTrace dump : dumps) { + + // entry 0 is the most detailed element in the stacktrace + // so create sub-stacks (1..n, 2..n, ...) to capture the most common root cause of an ANR + for (int i = 0; i < dump.stack.length - 1; i++) { + final int key = subArrayHashCode(dump.stack, i, dump.stack.length - 1); + int quality = 10; + final String clazz = dump.stack[i].getClassName(); + for (String ignoredPackage : lowQualityPackages) { + if (clazz.startsWith(ignoredPackage)) { + quality = 1; + break; + } + } + + @Nullable AggregatedStackTrace aggregatedStackTrace = stackTraceMap.get(key); + if (aggregatedStackTrace == null) { + aggregatedStackTrace = + new AggregatedStackTrace( + dump.stack, i, dump.stack.length - 1, dump.timestampMs, quality); + stackTraceMap.put(key, aggregatedStackTrace); + } else { + aggregatedStackTrace.add(dump.timestampMs); + } + } + } + + // the deepest stacktrace with most count wins + return Collections.max( + stackTraceMap.values(), + (c1, c2) -> { + final int countComparison = Integer.compare(c1.count * c1.quality, c2.count * c2.quality); + if (countComparison == 0) { + return Integer.compare(c1.depth, c2.depth); + } + return countComparison; + }); + } + + private static int subArrayHashCode( + final @NotNull Object[] arr, final int stackStartIdx, final int stackEndIdx) { + int result = 1; + for (int i = stackStartIdx; i <= stackEndIdx; i++) { + final Object item = arr[i]; + result = 31 * result + item.hashCode(); + } + return result; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java new file mode 100644 index 00000000000..99ab731e01b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java @@ -0,0 +1,15 @@ +package io.sentry.android.core.anr; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class AnrException extends Exception { + + private static final long serialVersionUID = 8615243433409006646L; + + public AnrException() {} + + public AnrException(String message) { + super(message); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java new file mode 100644 index 00000000000..2964721030e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -0,0 +1,32 @@ +package io.sentry.android.core.anr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class AnrProfile { + public final List stacks; + + public final long startTimeMs; + public final long endtimeMs; + + public AnrProfile(List stacks) { + this.stacks = new ArrayList<>(stacks.size()); + for (AnrStackTrace stack : stacks) { + if (stack != null) { + this.stacks.add(stack); + } + } + Collections.sort(this.stacks); + + if (!this.stacks.isEmpty()) { + startTimeMs = this.stacks.get(0).timestampMs; + endtimeMs = this.stacks.get(this.stacks.size() - 1).timestampMs + 10_000L; + } else { + startTimeMs = 0L; + endtimeMs = 0L; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java new file mode 100644 index 00000000000..7939a4dd32b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -0,0 +1,88 @@ +package io.sentry.android.core.anr; + +import static io.sentry.SentryLevel.ERROR; +import static io.sentry.android.core.anr.AnrProfilingIntegration.POLLING_INTERVAL_MS; +import static io.sentry.android.core.anr.AnrProfilingIntegration.THRESHOLD_ANR_MS; + +import io.sentry.ILogger; +import io.sentry.SentryOptions; +import io.sentry.cache.tape.ObjectQueue; +import io.sentry.cache.tape.QueueFile; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public class AnrProfileManager { + + private static final int MAX_NUM_STACKTRACES = + (int) ((THRESHOLD_ANR_MS / POLLING_INTERVAL_MS) * 2); + + @NotNull private final ObjectQueue queue; + + public AnrProfileManager(final @NotNull SentryOptions options) { + + final @NotNull File file = new File(options.getCacheDirPath(), "anr_profile"); + final @NotNull ILogger logger = options.getLogger(); + + @Nullable QueueFile queueFile = null; + try { + try { + queueFile = new QueueFile.Builder(file).size(MAX_NUM_STACKTRACES).build(); + } catch (IOException e) { + // if file is corrupted we simply delete it and try to create it again + if (!file.delete()) { + throw new IOException("Could not delete file"); + } + queueFile = new QueueFile.Builder(file).size(MAX_NUM_STACKTRACES).build(); + } + } catch (IOException e) { + logger.log(ERROR, "Failed to create stacktrace queue", e); + } + + if (queueFile == null) { + queue = ObjectQueue.createEmpty(); + } else { + queue = + ObjectQueue.create( + queueFile, + new ObjectQueue.Converter() { + @Override + public AnrStackTrace from(final byte[] source) throws IOException { + final @NotNull ByteArrayInputStream bis = new ByteArrayInputStream(source); + final @NotNull DataInputStream dis = new DataInputStream(bis); + return AnrStackTrace.deserialize(dis); + } + + @Override + public void toStream( + final @NotNull AnrStackTrace value, final @NotNull OutputStream sink) + throws IOException { + final @NotNull DataOutputStream dos = new DataOutputStream(sink); + value.serialize(dos); + dos.flush(); + sink.flush(); + } + }); + } + } + + public void clear() throws IOException { + queue.clear(); + } + + public void add(AnrStackTrace trace) throws IOException { + queue.add(trace); + } + + @NotNull + public AnrProfile load() throws IOException { + return new AnrProfile(queue.asList()); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java new file mode 100644 index 00000000000..7e8f2fe41f4 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -0,0 +1,195 @@ +package io.sentry.android.core.anr; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.NonNull; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Integration; +import io.sentry.NoOpLogger; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.AppState; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +public class AnrProfilingIntegration + implements Integration, Closeable, AppState.AppStateListener, Runnable { + + public static final long POLLING_INTERVAL_MS = 66; + private static final long THRESHOLD_SUSPICION_MS = 1000; + public static final long THRESHOLD_ANR_MS = 4000; + + private final AtomicBoolean enabled = new AtomicBoolean(true); + private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private volatile MainThreadState mainThreadState = MainThreadState.IDLE; + private volatile @Nullable AnrProfileManager profileManager; + private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); + private volatile @Nullable SentryOptions options; + private volatile @Nullable Thread thread = null; + private volatile boolean inForeground = false; + + @Override + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { + this.options = options; + logger = options.getLogger(); + AppState.getInstance().addAppStateListener(this); + } + + @Override + public void close() throws IOException { + onBackground(); + enabled.set(false); + } + + @Override + public void onForeground() { + if (!enabled.get()) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (inForeground) { + return; + } + inForeground = true; + + final @Nullable Thread oldThread = thread; + if (oldThread != null) { + oldThread.interrupt(); + } + + final @NotNull Thread newThread = new Thread(this, "AnrProfilingIntegration"); + newThread.start(); + thread = newThread; + } + } + + @Override + public void onBackground() { + if (!enabled.get()) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!inForeground) { + return; + } + + inForeground = false; + final @Nullable Thread oldThread = thread; + if (oldThread != null) { + oldThread.interrupt(); + } + } + } + + @Override + public void run() { + // get main thread Handler so we can post messages + final Looper mainLooper = Looper.getMainLooper(); + final Thread mainThread = mainLooper.getThread(); + final Handler mainHandler = new Handler(mainLooper); + + try { + while (enabled.get() && !Thread.currentThread().isInterrupted()) { + try { + checkMainThread(mainThread); + + mainHandler.removeCallbacks(updater); + mainHandler.post(updater); + + // noinspection BusyWait + Thread.sleep(POLLING_INTERVAL_MS); + } catch (InterruptedException e) { + // Restore interrupt status and exit the polling loop + Thread.currentThread().interrupt(); + return; + } + } + } catch (Throwable t) { + logger.log(SentryLevel.WARNING, "Failed execute AnrStacktraceIntegration", t); + } + } + + @ApiStatus.Internal + protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { + final long now = SystemClock.uptimeMillis(); + final long diff = now - lastMainThreadExecutionTime; + + if (diff < 1000) { + mainThreadState = MainThreadState.IDLE; + } + + if (mainThreadState == MainThreadState.IDLE && diff > THRESHOLD_SUSPICION_MS) { + logger.log(SentryLevel.DEBUG, "ANR: main thread is suspicious"); + mainThreadState = MainThreadState.SUSPICIOUS; + clearStacks(); + } + + // if we are suspicious, we need to collect stack traces + if (mainThreadState == MainThreadState.SUSPICIOUS + || mainThreadState == MainThreadState.ANR_DETECTED) { + final long start = SystemClock.uptimeMillis(); + final @NotNull AnrStackTrace trace = + new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); + final long duration = SystemClock.uptimeMillis() - start; + logger.log( + SentryLevel.DEBUG, + "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + + addStackTrace(trace); + } + + // TODO is this still required, + // maybe add stop condition + if (mainThreadState == MainThreadState.SUSPICIOUS && diff > THRESHOLD_ANR_MS) { + logger.log(SentryLevel.DEBUG, "ANR: main thread ANR threshold reached"); + mainThreadState = MainThreadState.ANR_DETECTED; + } + } + + @TestOnly + @NotNull + protected MainThreadState getState() { + return mainThreadState; + } + + @TestOnly + @NonNull + protected AnrProfileManager getProfileManager() { + final @Nullable AnrProfileManager r = profileManager; + if (r != null) { + return r; + } else { + final AnrProfileManager newManager = + new AnrProfileManager(Objects.requireNonNull(options, "Options can't be null")); + profileManager = newManager; + return newManager; + } + } + + private void clearStacks() throws IOException { + getProfileManager().clear(); + } + + private void addStackTrace(@NotNull final AnrStackTrace trace) throws IOException { + getProfileManager().add(trace); + } + + protected enum MainThreadState { + IDLE, + SUSPICIOUS, + ANR_DETECTED, + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java new file mode 100644 index 00000000000..8cea6ffe943 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java @@ -0,0 +1,68 @@ +package io.sentry.android.core.anr; + +import io.sentry.util.StringUtils; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class AnrStackTrace implements Comparable { + + public final StackTraceElement[] stack; + public final long timestampMs; + + public AnrStackTrace(final long timestampMs, final StackTraceElement[] stack) { + this.timestampMs = timestampMs; + this.stack = stack; + } + + @Override + public int compareTo(final @NotNull AnrStackTrace o) { + return Long.compare(timestampMs, o.timestampMs); + } + + public void serialize(final @NotNull DataOutputStream dos) throws IOException { + dos.writeShort(1); + dos.writeLong(timestampMs); + dos.writeInt(stack.length); + for (final @NotNull StackTraceElement element : stack) { + dos.writeUTF(StringUtils.getOrEmpty(element.getClassName())); + dos.writeUTF(StringUtils.getOrEmpty(element.getMethodName())); + dos.writeUTF(StringUtils.getOrEmpty(element.getFileName())); + dos.writeInt(element.getLineNumber()); + } + } + + @Nullable + public static AnrStackTrace deserialize(final @NotNull DataInputStream dis) throws IOException { + try { + final short version = dis.readShort(); + if (version == 1) { + final long timestampMs = dis.readLong(); + final int stackLength = dis.readInt(); + final @NotNull StackTraceElement[] stack = new StackTraceElement[stackLength]; + + for (int i = 0; i < stackLength; i++) { + final @NotNull String className = dis.readUTF(); + final @NotNull String methodName = dis.readUTF(); + final @Nullable String fileName = dis.readUTF(); + final int lineNumber = dis.readInt(); + final StackTraceElement element = + new StackTraceElement(className, methodName, fileName, lineNumber); + stack[i] = element; + } + + return new AnrStackTrace(timestampMs, stack); + } else { + // unsupported future version + return null; + } + } catch (EOFException e) { + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java new file mode 100644 index 00000000000..3344bc3516c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java @@ -0,0 +1,150 @@ +package io.sentry.android.core.anr; + +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.profiling.SentryProfile; +import io.sentry.protocol.profiling.SentrySample; +import io.sentry.protocol.profiling.SentryThreadMetadata; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Converts a list of {@link AnrStackTrace} objects captured during ANR detection into a {@link + * SentryProfile} object suitable for profiling telemetry. + * + *

This converter handles: + * + *

    + *
  • Converting {@link StackTraceElement} to {@link SentryStackFrame} + *
  • Deduplicating frames based on their signature + *
  • Building stack references using frame indices + *
  • Creating samples with timestamps + *
  • Populating thread metadata + *
+ */ +@ApiStatus.Internal +public final class StackTraceConverter { + + private static final String MAIN_THREAD_ID = "0"; + private static final String MAIN_THREAD_NAME = "main"; + + /** + * Converts a list of {@link AnrStackTrace} objects to a {@link SentryProfile}. + * + * @param anrProfile The ANR Profile + * @return a populated SentryProfile with deduped frames and samples + */ + @NotNull + public static SentryProfile convert(final @NotNull AnrProfile anrProfile) { + final @NotNull List anrStackTraces = anrProfile.stacks; + + final @NotNull SentryProfile profile = new SentryProfile(); + final @NotNull List frames = new ArrayList<>(); + final @NotNull Map frameSignatureToIndex = new HashMap<>(); + final @NotNull List> stacks = new ArrayList<>(); + final @NotNull Map stackSignatureToIndex = new HashMap<>(); + + for (final @NotNull AnrStackTrace anrStackTrace : anrStackTraces) { + final @NotNull StackTraceElement[] stackElements = anrStackTrace.stack; + final @NotNull List frameIndices = new ArrayList<>(); + for (final @NotNull StackTraceElement element : stackElements) { + final @NotNull String frameSignature = createFrameSignature(element); + @Nullable Integer frameIndex = frameSignatureToIndex.get(frameSignature); + if (frameIndex == null) { + frameIndex = frames.size(); + frames.add(createSentryStackFrame(element)); + frameSignatureToIndex.put(frameSignature, frameIndex); + } + frameIndices.add(frameIndex); + } + + final @NotNull String stackSignature = createStackSignature(frameIndices); + @Nullable Integer stackIndex = stackSignatureToIndex.get(stackSignature); + + if (stackIndex == null) { + stackIndex = stacks.size(); + stacks.add(new ArrayList<>(frameIndices)); + stackSignatureToIndex.put(stackSignature, stackIndex); + } + + final @NotNull SentrySample sample = new SentrySample(); + sample.setTimestamp(anrStackTrace.timestampMs / 1000.0); // Convert ms to seconds + sample.setStackId(stackIndex); + sample.setThreadId(MAIN_THREAD_ID); + + profile.getSamples().add(sample); + } + + profile.setFrames(frames); + profile.setStacks(stacks); + + final @NotNull SentryThreadMetadata threadMetadata = new SentryThreadMetadata(); + threadMetadata.setName(MAIN_THREAD_NAME); + threadMetadata.setPriority(Thread.NORM_PRIORITY); + + final @NotNull Map threadMetadataMap = new HashMap<>(); + threadMetadataMap.put(MAIN_THREAD_ID, threadMetadata); + profile.setThreadMetadata(threadMetadataMap); + + return profile; + } + + /** + * Creates a unique signature for a StackTraceElement to identify duplicate frames. + * + * @param element the stack trace element + * @return a signature string representing this frame + */ + @NotNull + private static String createFrameSignature(@NotNull StackTraceElement element) { + return element.getClassName() + + "#" + + element.getMethodName() + + "#" + + element.getFileName() + + "#" + + element.getLineNumber(); + } + + /** + * Creates a unique signature for a stack (list of frame indices) to identify duplicate stacks. + * + * @param frameIndices the list of frame indices + * @return a signature string representing this stack + */ + @NotNull + private static String createStackSignature(@NotNull List frameIndices) { + final @NotNull StringBuilder sb = new StringBuilder(); + for (Integer index : frameIndices) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(index); + } + return sb.toString(); + } + + /** + * Converts a {@link StackTraceElement} to a {@link SentryStackFrame}. + * + * @param element the stack trace element + * @return a SentryStackFrame populated with available information + */ + @NotNull + private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElement element) { + final @NotNull SentryStackFrame frame = new SentryStackFrame(); + frame.setFilename(element.getFileName()); + frame.setFunction(element.getMethodName()); + frame.setModule(element.getClassName()); + frame.setLineno(element.getLineNumber() > 0 ? element.getLineNumber() : null); + frame.setInApp(true); + if (element.isNativeMethod()) { + frame.setNative(true); + } + return frame; + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 3c94f0abf29..35e6adbd4d3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1882,4 +1882,29 @@ class ManifestMetadataReaderTest { fixture.options.sessionReplay.screenshotStrategy, ) } + + @Test + fun `applyMetadata reads enableAnrProfiling to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ENABLE_ANR_PROFILING to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableAnrProfiling) + } + + @Test + fun `applyMetadata reads enableAnrProfiling to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableAnrProfiling) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 8cb79b0bb5b..8c9e8b3152c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -195,6 +195,28 @@ class SentryAndroidOptionsTest { assertTrue(sentryOptions.isEnableSystemEventBreadcrumbsExtras) } + @Test + fun `anr profiling disabled by default`() { + val sentryOptions = SentryAndroidOptions() + + assertFalse(sentryOptions.isEnableAnrProfiling) + } + + @Test + fun `anr profiling can be enabled`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.isEnableAnrProfiling = true + assertTrue(sentryOptions.isEnableAnrProfiling) + } + + @Test + fun `anr profiling can be disabled`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.isEnableAnrProfiling = true + sentryOptions.isEnableAnrProfiling = false + assertFalse(sentryOptions.isEnableAnrProfiling) + } + private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt new file mode 100644 index 00000000000..fb78ce4a07e --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt @@ -0,0 +1,147 @@ +package io.sentry.android.core.anr + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AnrCulpritIdentifierTest { + + @Test + fun `returns null for empty dumps`() { + // Arrange + val dumps = emptyList() + + // Act + val result = AnrCulpritIdentifier.identify(dumps) + + // Assert + assertNull(result) + } + + @Test + fun `identifies single stack trace`() { + // Arrange + val stackTraceElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val dumps = listOf(AnrStackTrace(1000, stackTraceElements)) + + // Act + val result = AnrCulpritIdentifier.identify(dumps) + + // Assert + assertNotNull(result) + assertEquals(1, result.count) + assertTrue(result.depth > 0) + } + + @Test + fun `identifies most common stack trace from multiple dumps`() { + // Arrange + val commonElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val rareElements = + arrayOf( + StackTraceElement("com.example.RareClass", "rareMethod", "RareClass.java", 50), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val dumps = + listOf( + AnrStackTrace(1000, commonElements), + AnrStackTrace(2000, commonElements), + AnrStackTrace(3000, rareElements), + ) + + // Act + val result = AnrCulpritIdentifier.identify(dumps) + + // Assert + assertNotNull(result) + // The common element should have higher count (appears twice) vs rare (appears once) + assertEquals(2, result.count) + } + + @Test + fun `applies lower quality score to framework packages`() { + // Arrange + val frameworkElements = + arrayOf( + StackTraceElement("java.lang.Object", "wait", "Object.java", 42), + StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), + ) + val appElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), + ) + val dumps = + listOf( + AnrStackTrace(1000, frameworkElements), + AnrStackTrace(2000, frameworkElements), + AnrStackTrace(3000, appElements), + ) + + // Act + val result = AnrCulpritIdentifier.identify(dumps) + + // Assert + assertNotNull(result) + // Should identify a culprit from the stacks + assertTrue(result.count > 0) + } + + @Test + fun `prefers deeper stack traces on quality tie`() { + // Arrange + val shallowStack = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + val deepStack = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + StackTraceElement("com.example.ThirdClass", "method3", "ThirdClass.java", 150), + ) + val dumps = + listOf( + AnrStackTrace(1000, shallowStack), + AnrStackTrace(2000, shallowStack), + AnrStackTrace(3000, deepStack), + AnrStackTrace(4000, deepStack), + ) + + // Act + val result = AnrCulpritIdentifier.identify(dumps) + + // Assert + assertNotNull(result) + // Both have count 2, but deep stack should be preferred due to depth + assertTrue(result.depth >= 1) + } + + @Test + fun `handles mixed framework and app code`() { + // Arrange + val mixedElements = + arrayOf( + StackTraceElement("com.example.Activity", "onCreate", "Activity.java", 42), + StackTraceElement("com.example.DataProcessor", "process", "DataProcessor.java", 100), + StackTraceElement("java.lang.Thread", "run", "Thread.java", 50), + ) + val dumps = listOf(AnrStackTrace(1000, mixedElements)) + + // Act + val result = AnrCulpritIdentifier.identify(dumps) + + // Assert + assertNotNull(result) + // Should identify the custom app code as culprit, not the framework code + assertTrue(result.getStack().any { it.className.startsWith("com.example.") }) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt new file mode 100644 index 00000000000..8c8c36505be --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt @@ -0,0 +1,145 @@ +package io.sentry.android.core.anr + +import io.sentry.SentryOptions +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock + +class AnrProfileManagerTest { + private lateinit var tempDir: File + + @AfterTest + fun cleanup() { + if (::tempDir.isInitialized && tempDir.exists()) { + tempDir.deleteRecursively() + } + } + + private fun createOptions(): SentryOptions { + tempDir = Files.createTempDirectory("anr_profile_test").toFile() + val options = SentryOptions() + options.cacheDirPath = tempDir.absolutePath + options.setLogger(mock()) + return options + } + + @Test + fun `can add and load stack traces`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + val stackTraceElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val trace = AnrStackTrace(1000, stackTraceElements) + + // Act + manager.add(trace) + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertEquals(1, profile.stacks.size) + assertEquals(1000L, profile.stacks[0].timestampMs) + assertEquals(2, profile.stacks[0].stack.size) + } + + @Test + fun `can add multiple stack traces`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + val stackTraceElements1 = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + val stackTraceElements2 = + arrayOf(StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100)) + + // Act + manager.add(AnrStackTrace(1000, stackTraceElements1)) + manager.add(AnrStackTrace(2000, stackTraceElements2)) + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertEquals(2, profile.stacks.size) + assertEquals(1000L, profile.stacks[0].timestampMs) + assertEquals(2000L, profile.stacks[1].timestampMs) + } + + @Test + fun `can clear all stack traces`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + val stackTraceElements = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + manager.add(AnrStackTrace(1000, stackTraceElements)) + + // Act + manager.clear() + val profile = manager.load() + + // Assert + assertTrue(profile.stacks.isEmpty()) + } + + @Test + fun `load empty profile when nothing added`() { + // Arrange + val options = createOptions() + val manager = AnrProfileManager(options) + + // Act + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertTrue(profile.stacks.isEmpty()) + } + + @Test + fun `can deal with corrupt files`() { + // Arrange + val options = createOptions() + + val file = File(options.getCacheDirPath(), "anr_profile") + file.writeBytes("Hello World".toByteArray()) + + val manager = AnrProfileManager(options) + + // Act + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertTrue(profile.stacks.isEmpty()) + } + + @Test + fun `persists profiles across manager instances`() { + // Arrange + val options = createOptions() + val stackTraceElements = + arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + // Act - add profile with first manager + var manager = AnrProfileManager(options) + manager.add(AnrStackTrace(1000, stackTraceElements)) + + // Create new manager instance from same cache dir + manager = AnrProfileManager(options) + val profile = manager.load() + + // Assert + assertNotNull(profile) + assertEquals(1, profile.stacks.size) + assertEquals(1000L, profile.stacks[0].timestampMs) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt new file mode 100644 index 00000000000..c95834e75a1 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -0,0 +1,194 @@ +package io.sentry.android.core.anr + +import android.os.SystemClock +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.android.core.AppState +import io.sentry.test.getProperty +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AnrProfilingIntegrationTest { + private lateinit var tempDir: File + private lateinit var mockScopes: IScopes + private lateinit var mockLogger: ILogger + private lateinit var options: SentryOptions + + @BeforeTest + fun setup() { + tempDir = Files.createTempDirectory("anr_profile_test").toFile() + mockScopes = mock() + mockLogger = mock() + options = + SentryOptions().apply { + cacheDirPath = tempDir.absolutePath + setLogger(mockLogger) + } + AppState.getInstance().resetInstance() + } + + @AfterTest + fun cleanup() { + if (::tempDir.isInitialized && tempDir.exists()) { + tempDir.deleteRecursively() + } + AppState.getInstance().resetInstance() + } + + @Test + fun `onForeground starts monitoring thread`() { + // Arrange + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + // Act + integration.onForeground() + Thread.sleep(100) // Allow thread to start + + // Assert + val thread = integration.getProperty("thread") + assertNotNull(thread) + assertTrue(thread.isAlive) + assertEquals("AnrProfilingIntegration", thread.name) + } + + @Test + fun `onBackground stops monitoring thread`() { + // Arrange + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + Thread.sleep(100) + + val thread = integration.getProperty("thread") + assertNotNull(thread) + + // Act + integration.onBackground() + thread.join(2000) // Wait for thread to stop + + // Assert + assertTrue(!thread.isAlive) + } + + @Test + fun `close disables integration and interrupts thread`() { + // Arrange + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + Thread.sleep(100) + + val thread = integration.getProperty("thread") + assertNotNull(thread) + + // Act + integration.close() + thread.join(2000) + + // Assert + assertTrue(!thread.isAlive) + val enabled = integration.getProperty("enabled") + assertTrue(!enabled.get()) + } + + @Test + fun `lifecycle methods have no influence after close`() { + // Arrange + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.close() + integration.onForeground() + integration.onBackground() + + val thread = integration.getProperty("thread") + assertTrue(thread == null || !thread.isAlive) + } + + @Test + fun `multiple foreground calls do not create multiple threads`() { + // Arrange + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + // Act + integration.onForeground() + Thread.sleep(100) + val thread1 = integration.getProperty("thread") + + integration.onForeground() + Thread.sleep(100) + val thread2 = integration.getProperty("thread") + + // Assert + assertNotNull(thread1) + assertNotNull(thread2) + assertEquals(thread1, thread2, "Should reuse the same thread") + + integration.close() + } + + @Test + fun `foreground after background restarts thread`() { + // Arrange + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + // Act + integration.onForeground() + Thread.sleep(100) + val thread1 = integration.getProperty("thread") + + integration.onBackground() + integration.onForeground() + + Thread.sleep(100) + val thread2 = integration.getProperty("thread") + + // Assert + assertNotNull(thread1) + assertNotNull(thread2) + assertTrue(thread1 != thread2, "Should create a new thread after background") + + integration.close() + } + + @Test + fun `properly walks through state transitions and collects stack traces`() { + // Arrange + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_00) + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + + // Act + SystemClock.setCurrentTimeMillis(1_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + assertTrue(integration.profileManager.load().stacks.isEmpty()) + + SystemClock.setCurrentTimeMillis(3_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.SUSPICIOUS, integration.state) + + SystemClock.setCurrentTimeMillis(6_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) + assertEquals(2, integration.profileManager.load().stacks.size) + + integration.close() + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt new file mode 100644 index 00000000000..65e6c7c6370 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt @@ -0,0 +1,209 @@ +package io.sentry.android.core.anr + +import org.junit.Assert +import org.junit.Test + +class AnrStackTraceConverterTest { + @Test + fun testConvertSimpleStackTrace() { + // Create a simple stack trace + val elements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + + val anrStackTrace = AnrStackTrace(1000, elements) + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(anrStackTrace) + + // Convert to profile + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Verify profile structure + Assert.assertNotNull(profile) + Assert.assertEquals(1, profile.getSamples().size.toLong()) + Assert.assertEquals(2, profile.getFrames().size.toLong()) + Assert.assertEquals(1, profile.getStacks().size.toLong()) + + // Verify frames + val frame0 = profile.getFrames().get(0) + Assert.assertEquals("MyClass.java", frame0.getFilename()) + Assert.assertEquals("method1", frame0.getFunction()) + Assert.assertEquals("com.example.MyClass", frame0.getModule()) + Assert.assertEquals(42, frame0.getLineno()) + + val frame1 = profile.getFrames().get(1) + Assert.assertEquals("AnotherClass.java", frame1.getFilename()) + Assert.assertEquals("method2", frame1.getFunction()) + Assert.assertEquals("com.example.AnotherClass", frame1.getModule()) + Assert.assertEquals(100, frame1.getLineno()) + + // Verify stack + val stack = profile.getStacks().get(0) + Assert.assertEquals(2, stack.size.toLong()) + Assert.assertEquals(0, (stack.get(0) as Int).toLong()) + Assert.assertEquals(1, (stack.get(1) as Int).toLong()) + + // Verify sample + val sample = profile.getSamples().get(0) + Assert.assertEquals(0, sample.getStackId().toLong()) + Assert.assertEquals("0", sample.getThreadId()) + Assert.assertEquals(1.0, sample.getTimestamp(), 0.001) // 1000ms = 1s + } + + @Test + fun testFrameDeduplication() { + // Create two stack traces with duplicate frames + val elements1 = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + + val elements2 = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.ThirdClass", "method3", "ThirdClass.java", 200), + ) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements1)) + anrStackTraces.add(AnrStackTrace(2000, elements2)) + + // Convert to profile + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Should have 3 frames total (dedup removes duplicate) + Assert.assertEquals(3, profile.getFrames().size.toLong()) + + // First sample uses stack [0, 1] + val stack1 = profile.getStacks().get(0) + Assert.assertEquals(2, stack1.size.toLong()) + Assert.assertEquals(0, (stack1.get(0) as Int).toLong()) + Assert.assertEquals(1, (stack1.get(1) as Int).toLong()) + + // Second sample uses stack [0, 2] (frame 0 reused) + val stack2 = profile.getStacks().get(1) + Assert.assertEquals(2, stack2.size.toLong()) + Assert.assertEquals(0, (stack2.get(0) as Int).toLong()) + Assert.assertEquals(2, (stack2.get(1) as Int).toLong()) + } + + @Test + fun testStackDeduplication() { + // Create two stack traces with identical frames in same order + val elements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + anrStackTraces.add(AnrStackTrace(2000, elements.clone())) + + // Convert to profile + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Should have 2 frames and 1 stack (dedup stack) + Assert.assertEquals(2, profile.getFrames().size.toLong()) + Assert.assertEquals(1, profile.getStacks().size.toLong()) + + // Both samples should reference the same stack + Assert.assertEquals(0, profile.getSamples().get(0).getStackId().toLong()) + Assert.assertEquals(0, profile.getSamples().get(1).getStackId().toLong()) + } + + @Test + fun testTimestampConversion() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + // Test various timestamps + val timestampsMs = longArrayOf(1000, 1500, 5000) + val anrStackTraces: MutableList = ArrayList() + + for (ts in timestampsMs) { + anrStackTraces.add(AnrStackTrace(ts, elements)) + } + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Verify timestamps are converted from ms to seconds + Assert.assertEquals(1.0, profile.getSamples().get(0).getTimestamp(), 0.001) + Assert.assertEquals(1.5, profile.getSamples().get(1).getTimestamp(), 0.001) + Assert.assertEquals(5.0, profile.getSamples().get(2).getTimestamp(), 0.001) + } + + @Test + fun testNativeMethodHandling() { + // Create a native method stack trace + val elements = arrayOf(StackTraceElement("java.lang.System", "doSomething", null, -2)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val frame = profile.getFrames().get(0) + Assert.assertTrue(frame.isNative()!!) + } + + @Test + fun testThreadMetadata() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Verify thread metadata + val threadMetadata = profile.getThreadMetadata().get("0") + Assert.assertNotNull(threadMetadata) + Assert.assertEquals("main", threadMetadata!!.getName()) + Assert.assertEquals(Thread.NORM_PRIORITY.toLong(), threadMetadata.getPriority().toLong()) + } + + @Test + fun testEmptyStackTraceList() { + val anrStackTraces: MutableList = ArrayList() + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Should return empty profile with thread metadata + Assert.assertNotNull(profile) + Assert.assertEquals(0, profile.getSamples().size.toLong()) + Assert.assertEquals(0, profile.getFrames().size.toLong()) + Assert.assertEquals(0, profile.getStacks().size.toLong()) + Assert.assertTrue(profile.getThreadMetadata().containsKey("0")) + } + + @Test + fun testSampleProperties() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(12345, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val sample = profile.getSamples().get(0) + Assert.assertEquals("0", sample.getThreadId()) + Assert.assertEquals(0, sample.getStackId().toLong()) + Assert.assertEquals(12.345, sample.getTimestamp(), 0.001) + } + + @Test + fun testInAppFrameFlag() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val frame = profile.getFrames().get(0) + Assert.assertTrue(frame.isInApp()!!) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e21bead746d..3719d3aa967 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -189,6 +189,8 @@ + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fb241c9b4ef..3b2884a5bae 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7382,6 +7382,7 @@ public final class io/sentry/util/StringUtils { public static fun camelCase (Ljava/lang/String;)Ljava/lang/String; public static fun capitalize (Ljava/lang/String;)Ljava/lang/String; public static fun countOf (Ljava/lang/String;C)I + public static fun getOrEmpty (Ljava/lang/String;)Ljava/lang/String; public static fun getStringAfterDot (Ljava/lang/String;)Ljava/lang/String; public static fun join (Ljava/lang/CharSequence;Ljava/lang/Iterable;)Ljava/lang/String; public static fun normalizeUUID (Ljava/lang/String;)Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index 0aa6b7e524d..a6145ca8e9a 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -34,7 +34,7 @@ public final class ProfileChunk implements JsonUnknown, JsonSerializable { private @NotNull String version; private double timestamp; - private final @NotNull File traceFile; + private final @Nullable File traceFile; /** Profile trace encoded with Base64. */ private @Nullable String sampledProfile = null; @@ -47,7 +47,7 @@ public ProfileChunk() { this( SentryId.EMPTY_ID, SentryId.EMPTY_ID, - new File("dummy"), + null, new HashMap<>(), 0.0, PLATFORM_ANDROID, @@ -57,7 +57,7 @@ public ProfileChunk() { public ProfileChunk( final @NotNull SentryId profilerId, final @NotNull SentryId chunkId, - final @NotNull File traceFile, + final @Nullable File traceFile, final @NotNull Map measurements, final @NotNull Double timestamp, final @NotNull String platform, @@ -119,7 +119,7 @@ public void setSampledProfile(final @Nullable String sampledProfile) { this.sampledProfile = sampledProfile; } - public @NotNull File getTraceFile() { + public @Nullable File getTraceFile() { return traceFile; } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 04bf74fcfe8..b1bb7f44192 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -291,42 +291,44 @@ private static void ensureAttachmentSizeLimit( final @NotNull IProfileConverter profileConverter) throws SentryEnvelopeException { - final @NotNull File traceFile = profileChunk.getTraceFile(); + final @Nullable File traceFile = profileChunk.getTraceFile(); // Using CachedItem, so we read the trace file in the background final CachedItem cachedItem = new CachedItem( () -> { - if (!traceFile.exists()) { - throw new SentryEnvelopeException( - String.format( - "Dropping profile chunk, because the file '%s' doesn't exists", - traceFile.getName())); - } + if (traceFile != null) { + if (!traceFile.exists()) { + throw new SentryEnvelopeException( + String.format( + "Dropping profile chunk, because the file '%s' doesn't exists", + traceFile.getName())); + } - if (ProfileChunk.PLATFORM_JAVA.equals(profileChunk.getPlatform())) { - if (!NoOpProfileConverter.getInstance().equals(profileConverter)) { - try { - final SentryProfile profile = - profileConverter.convertFromFile(traceFile.getAbsolutePath()); - profileChunk.setSentryProfile(profile); - } catch (Exception e) { - throw new SentryEnvelopeException("Profile conversion failed", e); + if (ProfileChunk.PLATFORM_JAVA.equals(profileChunk.getPlatform())) { + if (!NoOpProfileConverter.getInstance().equals(profileConverter)) { + try { + final SentryProfile profile = + profileConverter.convertFromFile(traceFile.getAbsolutePath()); + profileChunk.setSentryProfile(profile); + } catch (Exception e) { + throw new SentryEnvelopeException("Profile conversion failed", e); + } + } else { + throw new SentryEnvelopeException( + "No ProfileConverter available, dropping chunk."); } } else { - throw new SentryEnvelopeException( - "No ProfileConverter available, dropping chunk."); - } - } else { - // The payload of the profile item is a json including the trace file encoded with - // base64 - final byte[] traceFileBytes = - readBytesFromFile(traceFile.getPath(), MAX_PROFILE_CHUNK_SIZE); - final @NotNull String base64Trace = - Base64.encodeToString(traceFileBytes, NO_WRAP | NO_PADDING); - if (base64Trace.isEmpty()) { - throw new SentryEnvelopeException("Profiling trace file is empty"); + // The payload of the profile item is a json including the trace file encoded with + // base64 + final byte[] traceFileBytes = + readBytesFromFile(traceFile.getPath(), MAX_PROFILE_CHUNK_SIZE); + final @NotNull String base64Trace = + Base64.encodeToString(traceFileBytes, NO_WRAP | NO_PADDING); + if (base64Trace.isEmpty()) { + throw new SentryEnvelopeException("Profiling trace file is empty"); + } + profileChunk.setSampledProfile(base64Trace); } - profileChunk.setSampledProfile(base64Trace); } try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); @@ -338,7 +340,9 @@ private static void ensureAttachmentSizeLimit( String.format("Failed to serialize profile chunk\n%s", e.getMessage())); } finally { // In any case we delete the trace file - traceFile.delete(); + if (traceFile != null) { + traceFile.delete(); + } } }); @@ -347,7 +351,7 @@ private static void ensureAttachmentSizeLimit( SentryItemType.ProfileChunk, () -> cachedItem.getBytes().length, "application-json", - traceFile.getName(), + traceFile != null ? traceFile.getName() : null, null, profileChunk.getPlatform(), null); diff --git a/sentry/src/main/java/io/sentry/util/StringUtils.java b/sentry/src/main/java/io/sentry/util/StringUtils.java index 14c247e71d2..66e3a95ddb7 100644 --- a/sentry/src/main/java/io/sentry/util/StringUtils.java +++ b/sentry/src/main/java/io/sentry/util/StringUtils.java @@ -26,6 +26,14 @@ public final class StringUtils { private StringUtils() {} + public static @NotNull String getOrEmpty(final @Nullable String str) { + if (str == null) { + return ""; + } else { + return str; + } + } + public static @Nullable String getStringAfterDot(final @Nullable String str) { if (str != null) { final int lastDotIndex = str.lastIndexOf("."); From a62b5e874ae964522367a0cb3dcad511720e299a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 12 Nov 2025 20:24:31 +0100 Subject: [PATCH 02/23] docs(changelog): Add ANR profiling integration entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4551f8ca2ef..f35f88abc36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Features + +- Add ANR profiling integration ([#4899](https://github.com/getsentry/sentry-java/pull/4899)) + - Captures main thread profile when ANR is detected + - Identifies culprit code causing application hangs + - Profiles are attached to ANR error events for better diagnostics + - Enable via `options.setEnableAnrProfiling(true)` or Android manifest: `` + ## 8.26.0 ### Features From f226d84c410eafa7baa47e27a080032c243df34b Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 13 Nov 2025 07:34:20 +0100 Subject: [PATCH 03/23] Fix api dump file --- sentry-android-core/api/sentry-android-core.api | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 63c1ffec606..7e1d19140a0 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -516,13 +516,24 @@ public class io/sentry/android/core/anr/AnrProfilingIntegration : io/sentry/Inte public static final field POLLING_INTERVAL_MS J public static final field THRESHOLD_ANR_MS J public fun ()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 From 7d423a479f3caafd50daf8b36b23219f2d6fa5b1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 13 Nov 2025 08:16:03 +0100 Subject: [PATCH 04/23] Address PR feedback --- .../api/sentry-android-core.api | 3 +- .../sentry/android/core/AnrV2Integration.java | 8 ++-- .../core/anr/AggregatedStackTrace.java | 2 +- .../core/anr/AnrCulpritIdentifier.java | 37 +++++++++++++------ .../sentry/android/core/anr/AnrProfile.java | 2 + .../android/core/anr/AnrProfileManager.java | 8 +++- .../core/anr/AnrProfilingIntegration.java | 34 +++++++++++------ 7 files changed, 63 insertions(+), 31 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7e1d19140a0..96fa8c21150 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -505,10 +505,11 @@ public class io/sentry/android/core/anr/AnrProfile { public fun (Ljava/util/List;)V } -public class io/sentry/android/core/anr/AnrProfileManager { +public class io/sentry/android/core/anr/AnrProfileManager : java/io/Closeable { public fun (Lio/sentry/SentryOptions;)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; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 562cf394fbe..df2c4fd8a7f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -322,8 +322,7 @@ private void applyAnrProfile( } @Nullable AnrProfile anrProfile = null; - try { - final AnrProfileManager provider = new AnrProfileManager(options); + try (final AnrProfileManager provider = new AnrProfileManager(options)) { anrProfile = provider.load(); } catch (Throwable t) { options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile"); @@ -331,7 +330,6 @@ private void applyAnrProfile( if (anrProfile != null) { options.getLogger().log(SentryLevel.INFO, "ANR profile found"); - // TODO maybe be less strict around the end timestamp if (anrTimestamp >= anrProfile.startTimeMs && anrTimestamp <= anrProfile.endtimeMs) { final SentryProfile profile = StackTraceConverter.convert(anrProfile); final ProfileChunk chunk = @@ -351,8 +349,8 @@ private void applyAnrProfile( final @Nullable AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); if (culprit != null) { - // TODO if quality is low (e.g. when culprit is pollNative()) - // consider throwing the ANR using a static fingerprint to reduce noise + // TODO Consider setting a static fingerprint to reduce noise + // if culprit quality is low (e.g. when culprit frame is pollNative()) final @NotNull StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { final StackTraceElement stackTraceElement = culprit.getStack()[0]; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java index e351c694ce6..001e79725b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java @@ -35,7 +35,7 @@ public AggregatedStackTrace( this.stack = stack; this.stackStartIdx = stackStartIdx; this.stackEndIdx = stackEndIdx; - this.depth = stackEndIdx - stackStartIdx; + this.depth = stackEndIdx - stackStartIdx + 1; this.startTimeMs = timestampMs; this.endTimeMs = timestampMs; this.count = 1; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java index 37af40887a0..81f85fedc25 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -15,7 +15,7 @@ public class AnrCulpritIdentifier { // common Java and Android packages who are less relevant for being the actual culprit private static final List lowQualityPackages = new ArrayList<>(9); - { + static { lowQualityPackages.add("java.lang"); lowQualityPackages.add("java.util"); lowQualityPackages.add("android.app"); @@ -28,25 +28,30 @@ public class AnrCulpritIdentifier { } /** - * @param dumps - * @return + * @param stacks the captured stacktraces + * @return the most common occurring stacktrace identified as the culprit */ @Nullable - public static AggregatedStackTrace identify(final @NotNull List dumps) { - if (dumps.isEmpty()) { + public static AggregatedStackTrace identify(final @NotNull List stacks) { + if (stacks.isEmpty()) { return null; } // fold all stacktraces and count their occurrences - final Map stackTraceMap = new HashMap<>(); - for (final AnrStackTrace dump : dumps) { + final @NotNull Map stackTraceMap = new HashMap<>(); + for (final AnrStackTrace stackTrace : stacks) { + + if (stackTrace.stack.length < 2) { + continue; + } // entry 0 is the most detailed element in the stacktrace // so create sub-stacks (1..n, 2..n, ...) to capture the most common root cause of an ANR - for (int i = 0; i < dump.stack.length - 1; i++) { - final int key = subArrayHashCode(dump.stack, i, dump.stack.length - 1); + for (int i = 0; i < stackTrace.stack.length - 1; i++) { + // TODO using hashcode is actually a bad key + final int key = subArrayHashCode(stackTrace.stack, i, stackTrace.stack.length - 1); int quality = 10; - final String clazz = dump.stack[i].getClassName(); + final String clazz = stackTrace.stack[i].getClassName(); for (String ignoredPackage : lowQualityPackages) { if (clazz.startsWith(ignoredPackage)) { quality = 1; @@ -58,14 +63,22 @@ public static AggregatedStackTrace identify(final @NotNull List d if (aggregatedStackTrace == null) { aggregatedStackTrace = new AggregatedStackTrace( - dump.stack, i, dump.stack.length - 1, dump.timestampMs, quality); + stackTrace.stack, + i, + stackTrace.stack.length - 1, + stackTrace.timestampMs, + quality); stackTraceMap.put(key, aggregatedStackTrace); } else { - aggregatedStackTrace.add(dump.timestampMs); + aggregatedStackTrace.add(stackTrace.timestampMs); } } } + if (stackTraceMap.isEmpty()) { + return null; + } + // the deepest stacktrace with most count wins return Collections.max( stackTraceMap.values(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java index 2964721030e..90a80e0a2dd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -23,6 +23,8 @@ public AnrProfile(List stacks) { if (!this.stacks.isEmpty()) { startTimeMs = this.stacks.get(0).timestampMs; + + // adding 10s to be less strict around end time endtimeMs = this.stacks.get(this.stacks.size() - 1).timestampMs + 10_000L; } else { startTimeMs = 0L; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java index 7939a4dd32b..75c58f3ef26 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -9,6 +9,7 @@ import io.sentry.cache.tape.ObjectQueue; import io.sentry.cache.tape.QueueFile; import java.io.ByteArrayInputStream; +import java.io.Closeable; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; @@ -19,7 +20,7 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public class AnrProfileManager { +public class AnrProfileManager implements Closeable { private static final int MAX_NUM_STACKTRACES = (int) ((THRESHOLD_ANR_MS / POLLING_INTERVAL_MS) * 2); @@ -85,4 +86,9 @@ public void add(AnrStackTrace trace) throws IOException { public AnrProfile load() throws IOException { return new AnrProfile(queue.asList()); } + + @Override + public void close() throws IOException { + queue.close(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 7e8f2fe41f4..5c7e156a135 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -31,7 +31,9 @@ public class AnrProfilingIntegration private final AtomicBoolean enabled = new AtomicBoolean(true); private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); + private final @NotNull AutoClosableReentrantLock profileManagerLock = + new AutoClosableReentrantLock(); private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); private volatile MainThreadState mainThreadState = MainThreadState.IDLE; @@ -52,6 +54,13 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { public void close() throws IOException { onBackground(); enabled.set(false); + + try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { + final @Nullable AnrProfileManager p = profileManager; + if (p != null) { + p.close(); + } + } } @Override @@ -59,7 +68,7 @@ public void onForeground() { if (!enabled.get()) { return; } - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + try (final @NotNull ISentryLifecycleToken ignored = lifecycleLock.acquire()) { if (inForeground) { return; } @@ -81,7 +90,7 @@ public void onBackground() { if (!enabled.get()) { return; } - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + try (final @NotNull ISentryLifecycleToken ignored = lifecycleLock.acquire()) { if (!inForeground) { return; } @@ -168,14 +177,17 @@ protected MainThreadState getState() { @TestOnly @NonNull protected AnrProfileManager getProfileManager() { - final @Nullable AnrProfileManager r = profileManager; - if (r != null) { - return r; - } else { - final AnrProfileManager newManager = - new AnrProfileManager(Objects.requireNonNull(options, "Options can't be null")); - profileManager = newManager; - return newManager; + try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { + final @Nullable AnrProfileManager r = profileManager; + if (r != null) { + return r; + } else { + + final AnrProfileManager newManager = + new AnrProfileManager(Objects.requireNonNull(options, "Options can't be null")); + profileManager = newManager; + return newManager; + } } } From 3d4d95273982324371c83f2d761ce26bcd3deecb Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 2 Dec 2025 14:42:20 +0100 Subject: [PATCH 05/23] refactor(anr): Implement lazy file rotation for ANR profiling --- .../api/sentry-android-core.api | 9 +++ .../core/AndroidOptionsInitializer.java | 3 + .../sentry/android/core/AnrV2Integration.java | 25 +++++-- .../android/core/anr/AnrProfileManager.java | 4 +- .../core/anr/AnrProfileRotationHelper.java | 66 +++++++++++++++++++ .../core/anr/AnrProfilingIntegration.java | 18 ++--- 6 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 96fa8c21150..cf2ec81722a 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -507,12 +507,21 @@ public class io/sentry/android/core/anr/AnrProfile { public class io/sentry/android/core/anr/AnrProfileManager : java/io/Closeable { public fun (Lio/sentry/SentryOptions;)V + public fun (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 ()V + public static fun deleteLastFile (Ljava/io/File;)Z + public static fun getCurrentFile (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 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 0dc8c486ccb..99755b7317c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -25,6 +25,7 @@ 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; @@ -138,6 +139,8 @@ static void loadDefaultAndMetadataOptions( .getRuntimeManager() .runWithRelaxedPolicy(() -> getCacheDir(finalContext).getAbsolutePath())); + AnrProfileRotationHelper.rotate(); + readDefaultOptionValues(options, finalContext, buildInfoProvider); AppState.getInstance().registerLifecycleObserver(options); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index df2c4fd8a7f..2004ed064be 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -24,6 +24,7 @@ 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; @@ -47,6 +48,7 @@ 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; @@ -322,10 +324,25 @@ private void applyAnrProfile( } @Nullable AnrProfile anrProfile = null; - try (final AnrProfileManager provider = new AnrProfileManager(options)) { - anrProfile = provider.load(); + final File cacheDir = new File(options.getCacheDirPath()); + + try { + final File lastFile = AnrProfileRotationHelper.getLastFile(cacheDir); + + if (lastFile.exists()) { + options.getLogger().log(SentryLevel.DEBUG, "Reading ANR profile from rotated file"); + 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"); + options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile", t); + } finally { + if (AnrProfileRotationHelper.deleteLastFile(cacheDir)) { + options.getLogger().log(SentryLevel.DEBUG, "Deleted old ANR profile file"); + } } if (anrProfile != null) { @@ -342,8 +359,6 @@ private void applyAnrProfile( ProfileChunk.PLATFORM_JAVA, options); chunk.setSentryProfile(profile); - - options.getLogger().log(SentryLevel.DEBUG, ""); scopes.captureProfileChunk(chunk); final @Nullable AggregatedStackTrace culprit = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java index 75c58f3ef26..66c4edcaac4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -28,8 +28,10 @@ public class AnrProfileManager implements Closeable { @NotNull private final ObjectQueue queue; public AnrProfileManager(final @NotNull SentryOptions options) { + this(options, new File(options.getCacheDirPath(), "anr_profile")); + } - final @NotNull File file = new File(options.getCacheDirPath(), "anr_profile"); + public AnrProfileManager(final @NotNull SentryOptions options, final @NotNull File file) { final @NotNull ILogger logger = options.getLogger(); @Nullable QueueFile queueFile = null; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java new file mode 100644 index 00000000000..7a24e88cf87 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java @@ -0,0 +1,66 @@ +package io.sentry.android.core.anr; + +import java.io.File; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Coordinates file rotation between AnrProfilingIntegration and AnrV2Integration to prevent + * concurrent access to the same QueueFile. + */ +@ApiStatus.Internal +public class AnrProfileRotationHelper { + + private static final String CURRENT_FILE_NAME = "anr_profile"; + private static final String OLD_FILE_NAME = "anr_profile_old"; + + private static final AtomicBoolean shouldRotate = new AtomicBoolean(false); + private static final Object rotationLock = new Object(); + + public static void rotate() { + shouldRotate.set(true); + } + + private static void performRotationIfNeeded(final @NotNull File cacheDir) { + if (!shouldRotate.get()) { + return; + } + + synchronized (rotationLock) { + if (!shouldRotate.get()) { + return; + } + + final File currentFile = new File(cacheDir, CURRENT_FILE_NAME); + final File oldFile = new File(cacheDir, OLD_FILE_NAME); + + if (oldFile.exists()) { + oldFile.delete(); + } + + if (currentFile.exists()) { + currentFile.renameTo(oldFile); + } + + shouldRotate.set(false); + } + } + + @NotNull + public static File getCurrentFile(final @NotNull File cacheDir) { + performRotationIfNeeded(cacheDir); + return new File(cacheDir, CURRENT_FILE_NAME); + } + + @NotNull + public static File getLastFile(final @NotNull File cacheDir) { + performRotationIfNeeded(cacheDir); + return new File(cacheDir, OLD_FILE_NAME); + } + + public static boolean deleteLastFile(final @NotNull File cacheDir) { + final File oldFile = new File(cacheDir, OLD_FILE_NAME); + return oldFile.exists() && oldFile.delete(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 5c7e156a135..906cdd3c9a2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -15,6 +15,7 @@ import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; @@ -178,16 +179,15 @@ protected MainThreadState getState() { @NonNull protected AnrProfileManager getProfileManager() { try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { - final @Nullable AnrProfileManager r = profileManager; - if (r != null) { - return r; - } else { - - final AnrProfileManager newManager = - new AnrProfileManager(Objects.requireNonNull(options, "Options can't be null")); - profileManager = newManager; - return newManager; + if (profileManager == null) { + final @NotNull SentryOptions opts = + Objects.requireNonNull(options, "Options can't be null"); + final @NotNull File currentFile = + AnrProfileRotationHelper.getCurrentFile(new File(opts.getCacheDirPath())); + profileManager = new AnrProfileManager(opts, currentFile); } + + return profileManager; } } From cee86fc337fc5dd6e769af87977c17dcec3157b3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 3 Dec 2025 08:42:22 +0100 Subject: [PATCH 06/23] Update Changelog --- CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d43f75ae5..d0ea0106ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### 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 + - Enable via `options.setEnableAnrProfiling(true)` or Android manifest: `` - Add option to capture additional OkHttp network request/response details in session replays ([#4919](https://github.com/getsentry/sentry-java/pull/4919)) - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies - To enable, add url regexes via the `io.sentry.session-replay.network-detail-allow-urls` metadata tag in AndroidManifest ([code sample](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205)) @@ -94,12 +98,6 @@ SentryAndroid.init( ### Improvements - Do not send manual log origin ([#4897](https://github.com/getsentry/sentry-java/pull/4897)) -- Add ANR profiling integration ([#4899](https://github.com/getsentry/sentry-java/pull/4899)) - - Captures main thread profile when ANR is detected - - Identifies culprit code causing application hangs - - Profiles are attached to ANR error events for better diagnostics - - Enable via `options.setEnableAnrProfiling(true)` or Android manifest: `` - ### Dependencies From 49c2e2016079c667b9de5354afb1776925cc1de8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 4 Dec 2025 15:55:49 +0100 Subject: [PATCH 07/23] Address PR feedback --- .../core/anr/AnrProfilingIntegration.java | 1 + .../core/anr/AnrProfilingIntegrationTest.kt | 20 +++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 906cdd3c9a2..e5ed5cc913b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -55,6 +55,7 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { public void close() throws IOException { onBackground(); enabled.set(false); + AppState.getInstance().removeAppStateListener(this); try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { final @Nullable AnrProfileManager p = profileManager; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index c95834e75a1..e2c7f067d25 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -48,15 +48,12 @@ class AnrProfilingIntegrationTest { @Test fun `onForeground starts monitoring thread`() { - // Arrange val integration = AnrProfilingIntegration() integration.register(mockScopes, options) - // Act integration.onForeground() Thread.sleep(100) // Allow thread to start - // Assert val thread = integration.getProperty("thread") assertNotNull(thread) assertTrue(thread.isAlive) @@ -65,7 +62,6 @@ class AnrProfilingIntegrationTest { @Test fun `onBackground stops monitoring thread`() { - // Arrange val integration = AnrProfilingIntegration() integration.register(mockScopes, options) integration.onForeground() @@ -74,17 +70,14 @@ class AnrProfilingIntegrationTest { val thread = integration.getProperty("thread") assertNotNull(thread) - // Act integration.onBackground() thread.join(2000) // Wait for thread to stop - // Assert assertTrue(!thread.isAlive) } @Test fun `close disables integration and interrupts thread`() { - // Arrange val integration = AnrProfilingIntegration() integration.register(mockScopes, options) integration.onForeground() @@ -93,19 +86,19 @@ class AnrProfilingIntegrationTest { val thread = integration.getProperty("thread") assertNotNull(thread) - // Act + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isNotEmpty()) + integration.close() thread.join(2000) - // Assert assertTrue(!thread.isAlive) val enabled = integration.getProperty("enabled") assertTrue(!enabled.get()) + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) } @Test fun `lifecycle methods have no influence after close`() { - // Arrange val integration = AnrProfilingIntegration() integration.register(mockScopes, options) integration.close() @@ -118,11 +111,9 @@ class AnrProfilingIntegrationTest { @Test fun `multiple foreground calls do not create multiple threads`() { - // Arrange val integration = AnrProfilingIntegration() integration.register(mockScopes, options) - // Act integration.onForeground() Thread.sleep(100) val thread1 = integration.getProperty("thread") @@ -131,7 +122,6 @@ class AnrProfilingIntegrationTest { Thread.sleep(100) val thread2 = integration.getProperty("thread") - // Assert assertNotNull(thread1) assertNotNull(thread2) assertEquals(thread1, thread2, "Should reuse the same thread") @@ -145,7 +135,6 @@ class AnrProfilingIntegrationTest { val integration = AnrProfilingIntegration() integration.register(mockScopes, options) - // Act integration.onForeground() Thread.sleep(100) val thread1 = integration.getProperty("thread") @@ -156,7 +145,6 @@ class AnrProfilingIntegrationTest { Thread.sleep(100) val thread2 = integration.getProperty("thread") - // Assert assertNotNull(thread1) assertNotNull(thread2) assertTrue(thread1 != thread2, "Should create a new thread after background") @@ -166,7 +154,6 @@ class AnrProfilingIntegrationTest { @Test fun `properly walks through state transitions and collects stack traces`() { - // Arrange val mainThread = Thread.currentThread() SystemClock.setCurrentTimeMillis(1_00) @@ -174,7 +161,6 @@ class AnrProfilingIntegrationTest { integration.register(mockScopes, options) integration.onForeground() - // Act SystemClock.setCurrentTimeMillis(1_000) integration.checkMainThread(mainThread) assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) From 93141dd07325cb6b01dc6c886cf43b710b6d80af Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 16 Dec 2025 11:21:36 +0100 Subject: [PATCH 08/23] Improve folding logic, cleanup tests --- .../api/sentry-android-core.api | 4 +- .../core/anr/AggregatedStackTrace.java | 14 +- .../core/anr/AnrCulpritIdentifier.java | 138 ++++++++++++------ .../core/anr/AnrCulpritIdentifierTest.kt | 74 +++++----- 4 files changed, 140 insertions(+), 90 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index cf2ec81722a..371f60ba1fd 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -483,8 +483,8 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr } public class io/sentry/android/core/anr/AggregatedStackTrace { - public fun ([Ljava/lang/StackTraceElement;IIJI)V - public fun add (J)V + public fun ([Ljava/lang/StackTraceElement;IIJF)V + public fun addOccurrence (J)V public fun getStack ()[Ljava/lang/StackTraceElement; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java index 001e79725b9..99eeeb7e004 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java @@ -2,14 +2,15 @@ import java.util.Arrays; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; @ApiStatus.Internal public class AggregatedStackTrace { // the number of frames of the stacktrace final int depth; - // the quality of the stack trace, higher means better - final int quality; + // the quality of the stack trace, higher means better (ratio of app frames: 0.0 to 1.0) + final float quality; private final StackTraceElement[] stack; @@ -20,10 +21,10 @@ public class AggregatedStackTrace { // the total number of times this exact stacktrace was captured int count; - // first time the stacktrace occured + // first time the stacktrace occurred private long startTimeMs; - // last time the stacktrace occured + // last time the stacktrace occurred private long endTimeMs; public AggregatedStackTrace( @@ -31,7 +32,7 @@ public AggregatedStackTrace( final int stackStartIdx, final int stackEndIdx, final long timestampMs, - final int quality) { + final float quality) { this.stack = stack; this.stackStartIdx = stackStartIdx; this.stackEndIdx = stackEndIdx; @@ -42,12 +43,13 @@ public AggregatedStackTrace( this.quality = quality; } - public void add(long timestampMs) { + public void addOccurrence(final long timestampMs) { this.startTimeMs = Math.min(startTimeMs, timestampMs); this.endTimeMs = Math.max(endTimeMs, timestampMs); this.count++; } + @NotNull public StackTraceElement[] getStack() { return Arrays.copyOfRange(stack, stackStartIdx, stackEndIdx + 1); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java index 81f85fedc25..76e3f1e7285 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -13,22 +13,79 @@ public class AnrCulpritIdentifier { // common Java and Android packages who are less relevant for being the actual culprit - private static final List lowQualityPackages = new ArrayList<>(9); + private static final List systemAndFrameWorkPackages = new ArrayList<>(9); static { - lowQualityPackages.add("java.lang"); - lowQualityPackages.add("java.util"); - lowQualityPackages.add("android.app"); - lowQualityPackages.add("android.os.Handler"); - lowQualityPackages.add("android.os.Looper"); - lowQualityPackages.add("android.view"); - lowQualityPackages.add("android.widget"); - lowQualityPackages.add("com.android.internal"); - lowQualityPackages.add("com.google.android"); + systemAndFrameWorkPackages.add("java.lang"); + systemAndFrameWorkPackages.add("java.util"); + systemAndFrameWorkPackages.add("android.app"); + systemAndFrameWorkPackages.add("android.os.Handler"); + systemAndFrameWorkPackages.add("android.os.Looper"); + systemAndFrameWorkPackages.add("android.view"); + systemAndFrameWorkPackages.add("android.widget"); + systemAndFrameWorkPackages.add("com.android.internal"); + systemAndFrameWorkPackages.add("com.google.android"); + } + + private static final class StackTraceKey { + private final @NotNull StackTraceElement[] stack; + private final int startIdx; + private final int endIdx; + private final int hashCode; + + StackTraceKey(final @NotNull StackTraceElement[] stack, final int startIdx, final int endIdx) { + this.stack = stack; + this.startIdx = startIdx; + this.endIdx = endIdx; + this.hashCode = computeHashCode(); + } + + private int computeHashCode() { + int result = 1; + for (int i = startIdx; i <= endIdx; i++) { + result = 31 * result + stack[i].hashCode(); + } + return result; + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof StackTraceKey)) { + return false; + } + + final @NotNull StackTraceKey other = (StackTraceKey) obj; + + if (hashCode != other.hashCode) { + return false; + } + + final int length = endIdx - startIdx + 1; + final int otherLength = other.endIdx - other.startIdx + 1; + if (length != otherLength) { + return false; + } + + for (int i = 0; i < length; i++) { + if (!stack[startIdx + i].equals(other.stack[other.startIdx + i])) { + return false; + } + } + + return true; + } } /** - * @param stacks the captured stacktraces + * @param stacks a list of stack traces to analyze * @return the most common occurring stacktrace identified as the culprit */ @Nullable @@ -38,27 +95,32 @@ public static AggregatedStackTrace identify(final @NotNull List s } // fold all stacktraces and count their occurrences - final @NotNull Map stackTraceMap = new HashMap<>(); - for (final AnrStackTrace stackTrace : stacks) { - + final @NotNull Map stackTraceMap = new HashMap<>(); + for (final @NotNull AnrStackTrace stackTrace : stacks) { if (stackTrace.stack.length < 2) { continue; } - // entry 0 is the most detailed element in the stacktrace - // so create sub-stacks (1..n, 2..n, ...) to capture the most common root cause of an ANR - for (int i = 0; i < stackTrace.stack.length - 1; i++) { - // TODO using hashcode is actually a bad key - final int key = subArrayHashCode(stackTrace.stack, i, stackTrace.stack.length - 1); - int quality = 10; - final String clazz = stackTrace.stack[i].getClassName(); - for (String ignoredPackage : lowQualityPackages) { - if (clazz.startsWith(ignoredPackage)) { - quality = 1; - break; - } + // stack[0] is the most detailed element in the stacktrace + // iterate from end to start (length-1 → 0) creating sub-stacks (i..n-1) to find the most + // common root cause + // count app frames from the end to compute quality scores + int appFramesCount = 0; + + for (int i = stackTrace.stack.length - 1; i >= 0; i--) { + + final @NotNull String topMostClassName = stackTrace.stack[i].getClassName(); + final boolean isSystemFrame = isSystemFrame(topMostClassName); + if (!isSystemFrame) { + appFramesCount++; } + final int totalFrames = stackTrace.stack.length - i; + final float quality = (float) appFramesCount / totalFrames; + + final @NotNull StackTraceKey key = + new StackTraceKey(stackTrace.stack, i, stackTrace.stack.length - 1); + @Nullable AggregatedStackTrace aggregatedStackTrace = stackTraceMap.get(key); if (aggregatedStackTrace == null) { aggregatedStackTrace = @@ -70,7 +132,7 @@ public static AggregatedStackTrace identify(final @NotNull List s quality); stackTraceMap.put(key, aggregatedStackTrace); } else { - aggregatedStackTrace.add(stackTrace.timestampMs); + aggregatedStackTrace.addOccurrence(stackTrace.timestampMs); } } } @@ -82,22 +144,16 @@ public static AggregatedStackTrace identify(final @NotNull List s // the deepest stacktrace with most count wins return Collections.max( stackTraceMap.values(), - (c1, c2) -> { - final int countComparison = Integer.compare(c1.count * c1.quality, c2.count * c2.quality); - if (countComparison == 0) { - return Integer.compare(c1.depth, c2.depth); - } - return countComparison; - }); + (c1, c2) -> + Float.compare(c1.count * c1.quality * c1.depth, c2.count * c2.quality * c2.depth)); } - private static int subArrayHashCode( - final @NotNull Object[] arr, final int stackStartIdx, final int stackEndIdx) { - int result = 1; - for (int i = stackStartIdx; i <= stackEndIdx; i++) { - final Object item = arr[i]; - result = 31 * result + item.hashCode(); + private static boolean isSystemFrame(final @NotNull String clazz) { + for (final String systemPackage : systemAndFrameWorkPackages) { + if (clazz.startsWith(systemPackage)) { + return true; + } } - return result; + return false; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt index fb78ce4a07e..db64a8c3a24 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt @@ -4,25 +4,18 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue class AnrCulpritIdentifierTest { @Test fun `returns null for empty dumps`() { - // Arrange val dumps = emptyList() - - // Act val result = AnrCulpritIdentifier.identify(dumps) - - // Assert assertNull(result) } @Test fun `identifies single stack trace`() { - // Arrange val stackTraceElements = arrayOf( StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), @@ -30,27 +23,25 @@ class AnrCulpritIdentifierTest { ) val dumps = listOf(AnrStackTrace(1000, stackTraceElements)) - // Act val result = AnrCulpritIdentifier.identify(dumps) - // Assert assertNotNull(result) assertEquals(1, result.count) - assertTrue(result.depth > 0) + assertEquals("com.example.MyClass", result.stack.first().className) + assertEquals(2, result.depth) } @Test - fun `identifies most common stack trace from multiple dumps`() { - // Arrange + fun `identifies most common, most detailed stack trace from multiple dumps`() { val commonElements = arrayOf( - StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), - StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + StackTraceElement("com.example.CommonClass", "commonMethod1", "CommonClass.java", 42), + StackTraceElement("com.example.CommonClass", "commonMethod2", "CommonClass.java", 100), ) val rareElements = arrayOf( StackTraceElement("com.example.RareClass", "rareMethod", "RareClass.java", 50), - StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + StackTraceElement("com.example.CommonClass", "commonMethod2", "CommonClass.java", 100), ) val dumps = listOf( @@ -59,18 +50,32 @@ class AnrCulpritIdentifierTest { AnrStackTrace(3000, rareElements), ) - // Act val result = AnrCulpritIdentifier.identify(dumps) - // Assert assertNotNull(result) - // The common element should have higher count (appears twice) vs rare (appears once) assertEquals(2, result.count) + assertEquals("com.example.CommonClass", result.stack.first().className) + assertEquals("commonMethod1", result.stack.first().methodName) + } + + @Test + fun `provides 0 quality score when stack only contains framework packages`() { + val frameworkElements = + arrayOf( + StackTraceElement("java.lang.Object", "wait", "Object.java", 42), + StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), + ) + val dumps = + listOf(AnrStackTrace(1000, frameworkElements), AnrStackTrace(2000, frameworkElements)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(0f, result.quality) } @Test fun `applies lower quality score to framework packages`() { - // Arrange val frameworkElements = arrayOf( StackTraceElement("java.lang.Object", "wait", "Object.java", 42), @@ -81,6 +86,7 @@ class AnrCulpritIdentifierTest { StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), StackTraceElement("android.os.Handler", "handleMessage", "Handler.java", 100), ) + val dumps = listOf( AnrStackTrace(1000, frameworkElements), @@ -88,46 +94,34 @@ class AnrCulpritIdentifierTest { AnrStackTrace(3000, appElements), ) - // Act val result = AnrCulpritIdentifier.identify(dumps) - // Assert assertNotNull(result) - // Should identify a culprit from the stacks - assertTrue(result.count > 0) + assertEquals("com.example.MyClass", result.stack.first().className) } @Test - fun `prefers deeper stack traces on quality tie`() { - // Arrange + fun `prefers deeper stack traces`() { val shallowStack = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + val deepStack = arrayOf( StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), StackTraceElement("com.example.ThirdClass", "method3", "ThirdClass.java", 150), ) - val dumps = - listOf( - AnrStackTrace(1000, shallowStack), - AnrStackTrace(2000, shallowStack), - AnrStackTrace(3000, deepStack), - AnrStackTrace(4000, deepStack), - ) + val dumps = listOf(AnrStackTrace(1000, shallowStack), AnrStackTrace(2000, deepStack)) - // Act val result = AnrCulpritIdentifier.identify(dumps) - // Assert assertNotNull(result) - // Both have count 2, but deep stack should be preferred due to depth - assertTrue(result.depth >= 1) + assertEquals(3, result.depth) + assertEquals("com.example.MyClass", result.stack.first().className) } @Test fun `handles mixed framework and app code`() { - // Arrange val mixedElements = arrayOf( StackTraceElement("com.example.Activity", "onCreate", "Activity.java", 42), @@ -136,12 +130,10 @@ class AnrCulpritIdentifierTest { ) val dumps = listOf(AnrStackTrace(1000, mixedElements)) - // Act val result = AnrCulpritIdentifier.identify(dumps) - // Assert assertNotNull(result) - // Should identify the custom app code as culprit, not the framework code - assertTrue(result.getStack().any { it.className.startsWith("com.example.") }) + assertEquals(2f / 3f, result.quality, 0.0001f) + assertEquals("com.example.Activity", result.stack.first().className) } } From a59bf086d75a01d1f44ef0c8c2f2d80c92e7e2ac Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 16 Dec 2025 16:25:16 +0100 Subject: [PATCH 09/23] Add more tests and address feedback --- .../api/sentry-android-core.api | 3 +- .../core/AndroidOptionsInitializer.java | 4 +- .../sentry/android/core/AnrV2Integration.java | 98 +++++++++++--- .../core/anr/AnrCulpritIdentifier.java | 24 ++-- .../core/anr/AnrProfileRotationHelper.java | 15 ++- .../core/anr/AnrProfilingIntegration.java | 35 +++-- .../android/core/anr/AnrStackTrace.java | 12 +- .../android/core/AnrV2IntegrationTest.kt | 60 +++++++++ .../sentry/android/core/SentryAndroidTest.kt | 4 +- .../core/anr/AnrCulpritIdentifierTest.kt | 33 +++++ .../core/anr/AnrProfileRotationHelperTest.kt | 121 ++++++++++++++++++ .../core/anr/AnrProfilingIntegrationTest.kt | 76 ++++++++++- .../android/core/anr/AnrStackTraceTest.kt | 97 ++++++++++++++ 13 files changed, 521 insertions(+), 61 deletions(-) create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 371f60ba1fd..5a7de10453e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -491,6 +491,7 @@ public class io/sentry/android/core/anr/AggregatedStackTrace { public class io/sentry/android/core/anr/AnrCulpritIdentifier { public fun ()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 { @@ -517,7 +518,7 @@ public class io/sentry/android/core/anr/AnrProfileManager : java/io/Closeable { public class io/sentry/android/core/anr/AnrProfileRotationHelper { public fun ()V public static fun deleteLastFile (Ljava/io/File;)Z - public static fun getCurrentFile (Ljava/io/File;)Ljava/io/File; + 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 } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 99755b7317c..f6a2313e893 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -395,9 +395,7 @@ static void installDefaultIntegrations( // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); - if (options.isEnableAnrProfiling()) { - options.addIntegration(new AnrProfilingIntegration()); - } + options.addIntegration(new AnrProfilingIntegration()); // registerActivityLifecycleCallbacks is only available if Context is an AppContext if (context instanceof Application) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 2004ed064be..56fb9f5417b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -37,7 +37,10 @@ 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; @@ -53,6 +56,7 @@ 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; @@ -298,7 +302,19 @@ private void reportAsSentryEvent( } } - applyAnrProfile(isBackground, anrTimestamp, event); + 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); @@ -323,14 +339,19 @@ private void applyAnrProfile( return; } + final @Nullable String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final @NotNull File cacheDir = new File(cacheDirPath); + @Nullable AnrProfile anrProfile = null; - final File cacheDir = new File(options.getCacheDirPath()); try { final File lastFile = AnrProfileRotationHelper.getLastFile(cacheDir); if (lastFile.exists()) { - options.getLogger().log(SentryLevel.DEBUG, "Reading ANR profile from rotated file"); + options.getLogger().log(SentryLevel.DEBUG, "Reading ANR profile"); try (final AnrProfileManager provider = new AnrProfileManager(options, lastFile)) { anrProfile = provider.load(); } @@ -340,32 +361,19 @@ private void applyAnrProfile( } catch (Throwable t) { options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile", t); } finally { - if (AnrProfileRotationHelper.deleteLastFile(cacheDir)) { - options.getLogger().log(SentryLevel.DEBUG, "Deleted old ANR profile file"); + 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 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); - scopes.captureProfileChunk(chunk); - final @Nullable AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); if (culprit != null) { - // TODO Consider setting a static fingerprint to reduce noise - // if culprit quality is low (e.g. when culprit frame is pollNative()) + final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @NotNull StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { final StackTraceElement stackTraceElement = culprit.getStack()[0]; @@ -378,7 +386,9 @@ private void applyAnrProfile( final SentryExceptionFactory factory = new SentryExceptionFactory(new SentryStackTraceFactory(options)); event.setExceptions(factory.getSentryExceptions(exception)); - event.getContexts().setProfile(new ProfileContext(chunk.getProfilerId())); + if (profilerId != null) { + event.getContexts().setProfile(new ProfileContext(profilerId)); + } } } } else { @@ -387,6 +397,28 @@ private void applyAnrProfile( } } + @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; @@ -440,6 +472,30 @@ private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException } } + private static boolean hasOnlySystemFrames(final @NotNull SentryEvent event) { + final List exceptions = event.getExceptions(); + if (exceptions != null) { + for (final SentryException exception : exceptions) { + final @Nullable SentryStackTrace stacktrace = exception.getStacktrace(); + if (stacktrace != null) { + final @Nullable List 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; + } + @ApiStatus.Internal public static final class AnrV2Hint extends BlockingFlushHint implements Backfillable, AbnormalExit { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java index 76e3f1e7285..5b3f955a0f8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -13,18 +13,18 @@ public class AnrCulpritIdentifier { // common Java and Android packages who are less relevant for being the actual culprit - private static final List systemAndFrameWorkPackages = new ArrayList<>(9); + private static final List systemAndFrameworkPackages = new ArrayList<>(9); static { - systemAndFrameWorkPackages.add("java.lang"); - systemAndFrameWorkPackages.add("java.util"); - systemAndFrameWorkPackages.add("android.app"); - systemAndFrameWorkPackages.add("android.os.Handler"); - systemAndFrameWorkPackages.add("android.os.Looper"); - systemAndFrameWorkPackages.add("android.view"); - systemAndFrameWorkPackages.add("android.widget"); - systemAndFrameWorkPackages.add("com.android.internal"); - systemAndFrameWorkPackages.add("com.google.android"); + systemAndFrameworkPackages.add("java.lang"); + systemAndFrameworkPackages.add("java.util"); + systemAndFrameworkPackages.add("android.app"); + systemAndFrameworkPackages.add("android.os.Handler"); + systemAndFrameworkPackages.add("android.os.Looper"); + systemAndFrameworkPackages.add("android.view"); + systemAndFrameworkPackages.add("android.widget"); + systemAndFrameworkPackages.add("com.android.internal"); + systemAndFrameworkPackages.add("com.google.android"); } private static final class StackTraceKey { @@ -148,8 +148,8 @@ public static AggregatedStackTrace identify(final @NotNull List s Float.compare(c1.count * c1.quality * c1.depth, c2.count * c2.quality * c2.depth)); } - private static boolean isSystemFrame(final @NotNull String clazz) { - for (final String systemPackage : systemAndFrameWorkPackages) { + public static boolean isSystemFrame(final @NotNull String clazz) { + for (final String systemPackage : systemAndFrameworkPackages) { if (clazz.startsWith(systemPackage)) { return true; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java index 7a24e88cf87..98a9ef8fb2b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java @@ -12,10 +12,10 @@ @ApiStatus.Internal public class AnrProfileRotationHelper { - private static final String CURRENT_FILE_NAME = "anr_profile"; + private static final String RECORDING_FILE_NAME = "anr_profile"; private static final String OLD_FILE_NAME = "anr_profile_old"; - private static final AtomicBoolean shouldRotate = new AtomicBoolean(false); + private static final AtomicBoolean shouldRotate = new AtomicBoolean(true); private static final Object rotationLock = new Object(); public static void rotate() { @@ -32,7 +32,7 @@ private static void performRotationIfNeeded(final @NotNull File cacheDir) { return; } - final File currentFile = new File(cacheDir, CURRENT_FILE_NAME); + final File currentFile = new File(cacheDir, RECORDING_FILE_NAME); final File oldFile = new File(cacheDir, OLD_FILE_NAME); if (oldFile.exists()) { @@ -48,9 +48,9 @@ private static void performRotationIfNeeded(final @NotNull File cacheDir) { } @NotNull - public static File getCurrentFile(final @NotNull File cacheDir) { + public static File getFileForRecording(final @NotNull File cacheDir) { performRotationIfNeeded(cacheDir); - return new File(cacheDir, CURRENT_FILE_NAME); + return new File(cacheDir, RECORDING_FILE_NAME); } @NotNull @@ -61,6 +61,9 @@ public static File getLastFile(final @NotNull File cacheDir) { public static boolean deleteLastFile(final @NotNull File cacheDir) { final File oldFile = new File(cacheDir, OLD_FILE_NAME); - return oldFile.exists() && oldFile.delete(); + if (!oldFile.exists()) { + return true; + } + return oldFile.delete(); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index e5ed5cc913b..4f99c996607 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -1,9 +1,10 @@ package io.sentry.android.core.anr; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.os.Handler; import android.os.Looper; import android.os.SystemClock; -import androidx.annotation.NonNull; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -12,6 +13,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.AppState; +import io.sentry.android.core.SentryAndroidOptions; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; @@ -40,15 +42,26 @@ public class AnrProfilingIntegration private volatile MainThreadState mainThreadState = MainThreadState.IDLE; private volatile @Nullable AnrProfileManager profileManager; private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); - private volatile @Nullable SentryOptions options; + private volatile @Nullable SentryAndroidOptions options; private volatile @Nullable Thread thread = null; private volatile boolean inForeground = false; @Override - public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { - this.options = options; - logger = options.getLogger(); - AppState.getInstance().addAppStateListener(this); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + this.options = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + this.logger = options.getLogger(); + + if (this.options == null) { + return; + } + + if (((SentryAndroidOptions) options).isEnableAnrProfiling()) { + addIntegrationToSdkVersion("AnrProfiling"); + AppState.getInstance().addAppStateListener(this); + } } @Override @@ -81,9 +94,9 @@ public void onForeground() { oldThread.interrupt(); } - final @NotNull Thread newThread = new Thread(this, "AnrProfilingIntegration"); - newThread.start(); - thread = newThread; + final @NotNull Thread profilingThread = new Thread(this, "AnrProfilingIntegration"); + profilingThread.start(); + thread = profilingThread; } } @@ -177,14 +190,14 @@ protected MainThreadState getState() { } @TestOnly - @NonNull + @NotNull protected AnrProfileManager getProfileManager() { try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { if (profileManager == null) { final @NotNull SentryOptions opts = Objects.requireNonNull(options, "Options can't be null"); final @NotNull File currentFile = - AnrProfileRotationHelper.getCurrentFile(new File(opts.getCacheDirPath())); + AnrProfileRotationHelper.getFileForRecording(new File(opts.getCacheDirPath())); profileManager = new AnrProfileManager(opts, currentFile); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java index 8cea6ffe943..64d486cf52b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java @@ -26,13 +26,16 @@ public int compareTo(final @NotNull AnrStackTrace o) { } public void serialize(final @NotNull DataOutputStream dos) throws IOException { - dos.writeShort(1); + dos.writeShort(1); // version dos.writeLong(timestampMs); dos.writeInt(stack.length); for (final @NotNull StackTraceElement element : stack) { dos.writeUTF(StringUtils.getOrEmpty(element.getClassName())); dos.writeUTF(StringUtils.getOrEmpty(element.getMethodName())); - dos.writeUTF(StringUtils.getOrEmpty(element.getFileName())); + // Write null as a special marker to preserve null vs empty string distinction + final @Nullable String fileName = element.getFileName(); + dos.writeBoolean(fileName == null); + dos.writeUTF(fileName == null ? "" : fileName); dos.writeInt(element.getLineNumber()); } } @@ -49,7 +52,10 @@ public static AnrStackTrace deserialize(final @NotNull DataInputStream dis) thro for (int i = 0; i < stackLength; i++) { final @NotNull String className = dis.readUTF(); final @NotNull String methodName = dis.readUTF(); - final @Nullable String fileName = dis.readUTF(); + // Read the null marker to restore null vs empty string distinction + final boolean isFileNameNull = dis.readBoolean(); + final @NotNull String fileNameStr = dis.readUTF(); + final @Nullable String fileName = isFileNameNull ? null : fileNameStr; final int lineNumber = dis.readInt(); final StackTraceElement element = new StackTraceElement(className, methodName, fileName, lineNumber); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index af2c208440d..4ca57d1fa17 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -602,4 +602,64 @@ class AnrV2IntegrationTest { verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } + + @Test + fun `when ANR has only system frames, sets custom fingerprint`() { + fixture.options.isEnableAnrProfiling = true + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo( + timestamp = newTimestamp, + importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, + ) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent( + check { + assertNotNull(it.fingerprints) + assertEquals(2, it.fingerprints!!.size) + assertEquals("{{ system-frames-only-anr }}", it.fingerprints!![0]) + assertEquals("foreground-anr", it.fingerprints!![1]) + }, + anyOrNull(), + ) + } + + @Test + fun `when ANR profiling is disabled, does not set custom fingerprint`() { + fixture.options.isEnableAnrProfiling = false + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent(check { assertEquals(null, it.fingerprints) }, anyOrNull()) + } + + @Test + fun `when captureProfileChunk returns empty ID, does not set profile context`() { + fixture.options.isEnableAnrProfiling = true + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + whenever(fixture.scopes.captureProfileChunk(any())).thenReturn(SentryId.EMPTY_ID) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes) + .captureEvent(check { assertEquals(null, it.contexts.profile) }, anyOrNull()) + } + + @Test + fun `when cacheDirPath is null, does not apply ANR profile`() { + fixture.options.isEnableAnrProfiling = true + fixture.options.cacheDirPath = null + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureProfileChunk(any()) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index e2d92239f19..87a62e0088d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -29,6 +29,7 @@ import io.sentry.ShutdownHookIntegration import io.sentry.SpotlightIntegration import io.sentry.SystemOutLogger import io.sentry.UncaughtExceptionHandlerIntegration +import io.sentry.android.core.anr.AnrProfilingIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration @@ -476,7 +477,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(18, options.integrations.size) + assertEquals(19, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -485,6 +486,7 @@ class SentryAndroidTest { it is EnvelopeFileObserverIntegration || it is AppLifecycleIntegration || it is AnrIntegration || + it is AnrProfilingIntegration || it is ActivityLifecycleIntegration || it is ActivityBreadcrumbsIntegration || it is UserInteractionIntegration || diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt index db64a8c3a24..a3023829ea8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt @@ -136,4 +136,37 @@ class AnrCulpritIdentifierTest { assertEquals(2f / 3f, result.quality, 0.0001f) assertEquals("com.example.Activity", result.stack.first().className) } + + @Test + fun `isSystemFrame returns true for java lang packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("java.lang.Object")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("java.lang.Thread")) + } + + @Test + fun `isSystemFrame returns true for java util packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("java.util.ArrayList")) + } + + @Test + fun `isSystemFrame returns true for android packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.app.Activity")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.os.Handler")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.os.Looper")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.view.View")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("android.widget.TextView")) + } + + @Test + fun `isSystemFrame returns true for internal android packages`() { + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("com.android.internal.os.ZygoteInit")) + assertEquals(true, AnrCulpritIdentifier.isSystemFrame("com.google.android.gms.common.api.Api")) + } + + @Test + fun `isSystemFrame returns false for app packages`() { + assertEquals(false, AnrCulpritIdentifier.isSystemFrame("com.example.MyClass")) + assertEquals(false, AnrCulpritIdentifier.isSystemFrame("io.sentry.samples.MainActivity")) + assertEquals(false, AnrCulpritIdentifier.isSystemFrame("org.myapp.Feature")) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt new file mode 100644 index 00000000000..1cc183d8931 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt @@ -0,0 +1,121 @@ +package io.sentry.android.core.anr + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AnrProfileRotationHelperTest { + private lateinit var tempDir: File + + @BeforeTest + fun setup() { + tempDir = Files.createTempDirectory("anr_profile_rotation_test").toFile() + AnrProfileRotationHelper.rotate() + } + + @AfterTest + fun cleanup() { + if (::tempDir.isInitialized && tempDir.exists()) { + tempDir.deleteRecursively() + } + } + + @Test + fun `getFileForRecording returns file with correct name`() { + val file = AnrProfileRotationHelper.getFileForRecording(tempDir) + + assertEquals("anr_profile", file.name) + assertEquals(tempDir, file.parentFile) + } + + @Test + fun `getLastFile returns last file`() { + val file = AnrProfileRotationHelper.getLastFile(tempDir) + + assertEquals("anr_profile_old", file.name) + assertEquals(tempDir, file.parentFile) + } + + @Test + fun `deleteLastFile returns true when file does not exist`() { + val result = AnrProfileRotationHelper.deleteLastFile(tempDir) + + assertTrue(result) + } + + @Test + fun `deleteLastFile returns true when file is deleted successfully`() { + val lastFile = File(tempDir, "anr_profile_old") + lastFile.writeText("test content") + assertTrue(lastFile.exists()) + + val result = AnrProfileRotationHelper.deleteLastFile(tempDir) + + assertTrue(result) + assertFalse(lastFile.exists()) + } + + @Test + fun `rotate moves current file to last file`() { + val currentFile = File(tempDir, "anr_profile") + currentFile.writeText("current content") + + val lastFile = AnrProfileRotationHelper.getLastFile(tempDir) + + assertTrue(lastFile.exists()) + assertEquals("current content", lastFile.readText()) + } + + @Test + fun `rotate deletes existing last file before moving`() { + val currentFile = File(tempDir, "anr_profile") + val lastFile = File(tempDir, "anr_profile_old") + + lastFile.writeText("last content") + currentFile.writeText("current content") + + assertTrue(lastFile.exists()) + assertTrue(currentFile.exists()) + + val newLastFile = AnrProfileRotationHelper.getLastFile(tempDir) + + assertTrue(newLastFile.exists()) + assertEquals("current content", newLastFile.readText()) + } + + @Test + fun `rotate does not directly perform file renaming`() { + val currentFile = File(tempDir, "anr_profile") + currentFile.writeText("current") + + val lastFile = File(tempDir, "anr_profile_old") + lastFile.writeText("last") + + AnrProfileRotationHelper.rotate() + + // content is still the same + assertEquals("current", currentFile.readText()) + assertEquals("last", lastFile.readText()) + + // but once rotated, the last file should now contain the current file's content + AnrProfileRotationHelper.getFileForRecording(tempDir) + assertEquals("current", lastFile.readText()) + } + + @Test + fun `getFileForRecording triggers rotation when needed`() { + val currentFile = File(tempDir, "anr_profile") + currentFile.writeText("content before rotation") + + AnrProfileRotationHelper.getFileForRecording(tempDir) + + val lastFile = File(tempDir, "anr_profile_old") + assertTrue(lastFile.exists()) + assertEquals("content before rotation", lastFile.readText()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index e2c7f067d25..f1667f00a54 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -5,6 +5,7 @@ import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryOptions import io.sentry.android.core.AppState +import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty import java.io.File import java.nio.file.Files @@ -12,6 +13,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.junit.runner.RunWith @@ -23,7 +25,7 @@ class AnrProfilingIntegrationTest { private lateinit var tempDir: File private lateinit var mockScopes: IScopes private lateinit var mockLogger: ILogger - private lateinit var options: SentryOptions + private lateinit var options: SentryAndroidOptions @BeforeTest fun setup() { @@ -31,9 +33,10 @@ class AnrProfilingIntegrationTest { mockScopes = mock() mockLogger = mock() options = - SentryOptions().apply { + SentryAndroidOptions().apply { cacheDirPath = tempDir.absolutePath setLogger(mockLogger) + isEnableAnrProfiling = true } AppState.getInstance().resetInstance() } @@ -157,8 +160,15 @@ class AnrProfilingIntegrationTest { val mainThread = Thread.currentThread() SystemClock.setCurrentTimeMillis(1_00) + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tempDir.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + val integration = AnrProfilingIntegration() - integration.register(mockScopes, options) + integration.register(mockScopes, androidOptions) integration.onForeground() SystemClock.setCurrentTimeMillis(1_000) @@ -177,4 +187,64 @@ class AnrProfilingIntegrationTest { integration.close() } + + @Test + fun `does not register when options is not SentryAndroidOptions`() { + val plainOptions = + SentryOptions().apply { + cacheDirPath = tempDir.absolutePath + setLogger(mockLogger) + } + + val integration = AnrProfilingIntegration() + + try { + integration.register(mockScopes, plainOptions) + } catch (e: IllegalArgumentException) { + // ignored + } + + // Verify no listeners were added + val lifecycleObserver = AppState.getInstance().lifecycleObserver + if (lifecycleObserver != null) { + assertTrue(lifecycleObserver.listeners.isEmpty()) + } + } + + @Test + fun `does not register when ANR profiling is disabled`() { + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tempDir.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = false + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + // When ANR profiling is disabled, the integration doesn't add itself to AppState + // So the lifecycle observer may be null or have no listeners + val lifecycleObserver = AppState.getInstance().lifecycleObserver + if (lifecycleObserver != null) { + assertTrue(lifecycleObserver.listeners.isEmpty()) + } + } + + @Test + fun `registers when ANR profiling is enabled`() { + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tempDir.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + assertFalse(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + + integration.close() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt new file mode 100644 index 00000000000..56ff85ee0d5 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt @@ -0,0 +1,97 @@ +package io.sentry.android.core.anr + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AnrStackTraceTest { + + @Test + fun `serialize and deserialize preserves stack trace data`() { + val stackTraceElements = + arrayOf( + StackTraceElement("com.example.MyClass", "method1", null, 42), + StackTraceElement("com.example.MyClass", "method1", "", 42), + StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), + ) + val original = AnrStackTrace(1234567890L, stackTraceElements) + + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + original.serialize(dos) + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNotNull(deserialized) + assertEquals(original.timestampMs, deserialized.timestampMs) + assertEquals(original.stack.size, deserialized.stack.size) + + for (i in original.stack.indices) { + assertEquals(original.stack[i].className, deserialized.stack[i].className) + assertEquals(original.stack[i].methodName, deserialized.stack[i].methodName) + assertEquals(original.stack[i].fileName, deserialized.stack[i].fileName) + assertEquals(original.stack[i].lineNumber, deserialized.stack[i].lineNumber) + } + } + + @Test + fun `compareTo sorts by timestamp ascending`() { + val trace1 = AnrStackTrace(3000L, emptyArray()) + val trace2 = AnrStackTrace(1000L, emptyArray()) + val trace3 = AnrStackTrace(2000L, emptyArray()) + + val list = listOf(trace3, trace1, trace2) + val sorted = list.sorted() + + assertEquals(1000L, sorted[0].timestampMs) + assertEquals(2000L, sorted[1].timestampMs) + assertEquals(3000L, sorted[2].timestampMs) + } + + @Test + fun `serialize and deserialize handles empty stack`() { + val original = AnrStackTrace(1234567890L, emptyArray()) + + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + original.serialize(dos) + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNotNull(deserialized) + assertEquals(0, deserialized.stack.size) + assertEquals(original.timestampMs, deserialized.timestampMs) + } + + @Test + fun `serialize and deserialize handles native methods with no line number`() { + val stackTraceElements = + arrayOf( + StackTraceElement("java.lang.reflect.Method", "invoke", null, -2), + StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), + ) + val original = AnrStackTrace(1234567890L, stackTraceElements) + + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + original.serialize(dos) + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNotNull(deserialized) + assertEquals(-2, deserialized.stack[0].lineNumber) + assertNull(deserialized.stack[0].fileName) + assertEquals(42, deserialized.stack[1].lineNumber) + } +} From 6c9acd74989d4bc1150294252bbab86e71241075 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 17 Dec 2025 10:50:40 +0100 Subject: [PATCH 10/23] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f8ed4a8a74..d33512e5537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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: `` ## 8.29.0 From e67c7134568858afb46bc7a6d017c231067f6195 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 30 Jan 2026 08:24:40 +0100 Subject: [PATCH 11/23] Address PR feedcback --- .../sentry/android/core/AnrV2Integration.java | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index a126ba57c4c..a9b4584aeb2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -367,20 +367,23 @@ private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException private static boolean hasOnlySystemFrames(final @NotNull SentryEvent event) { final List exceptions = event.getExceptions(); - if (exceptions != null) { - for (final SentryException exception : exceptions) { - final @Nullable SentryStackTrace stacktrace = exception.getStacktrace(); - if (stacktrace != null) { - final @Nullable List 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; - } + if (exceptions == null || exceptions.isEmpty()) { + // No exceptions means we haven't verified frames - don't apply special fingerprinting + return false; + } + + for (final SentryException exception : exceptions) { + final @Nullable SentryStackTrace stacktrace = exception.getStacktrace(); + if (stacktrace != null) { + final @Nullable List 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; } } } From aefa921ae5b067fef6ff764200269d0ae69f575d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 11 Feb 2026 11:30:23 +0100 Subject: [PATCH 12/23] Move logic to event processor --- .../sentry/android/core/AnrV2Integration.java | 147 -------------- .../ApplicationExitInfoEventProcessor.java | 162 +++++++++++++++- .../android/core/AnrV2IntegrationTest.kt | 114 ----------- .../ApplicationExitInfoEventProcessorTest.kt | 182 ++++++++++++++++++ 4 files changed, 343 insertions(+), 262 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 8785693123b..4797369ffa8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -12,20 +12,9 @@ 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; @@ -35,12 +24,8 @@ 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; @@ -49,12 +34,9 @@ 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.Arrays; -import java.util.HashMap; import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -207,111 +189,9 @@ public boolean shouldReportHistorical() { } } - 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")); - } - } - return new ApplicationExitInfoHistoryDispatcher.Report(event, hint, anrHint); } - 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 (SentryId.EMPTY_ID.equals(profilerId)) { - return null; - } else { - return chunk.getProfilerId(); - } - } - private @NotNull ParseResult parseThreadDump( final @NotNull ApplicationExitInfo exitInfo, final boolean isBackground) { final byte[] dump; @@ -365,33 +245,6 @@ private byte[] getDumpBytes(final @NotNull InputStream trace) throws IOException } } - private static boolean hasOnlySystemFrames(final @NotNull SentryEvent event) { - final List exceptions = event.getExceptions(); - if (exceptions == null || exceptions.isEmpty()) { - // No exceptions means we haven't verified frames - don't apply special fingerprinting - return false; - } - - for (final SentryException exception : exceptions) { - final @Nullable SentryStackTrace stacktrace = exception.getStacktrace(); - if (stacktrace != null) { - final @Nullable List 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; - } - @ApiStatus.Internal public static final class AnrV2Hint extends BlockingFlushHint implements Backfillable, AbnormalExit { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index ec60bd8f128..27e891012a0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -29,6 +29,9 @@ import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IpAddressUtils; +import io.sentry.ProfileChunk; +import io.sentry.ProfileContext; +import io.sentry.Sentry; import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryExceptionFactory; @@ -36,6 +39,13 @@ import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; +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.internal.util.CpuInfoUtils; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; @@ -50,10 +60,14 @@ import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.Request; import io.sentry.protocol.SdkVersion; +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.SentryTransaction; import io.sentry.protocol.User; +import io.sentry.protocol.profiling.SentryProfile; import io.sentry.util.HintUtils; import io.sentry.util.SentryRandom; import java.io.File; @@ -700,8 +714,15 @@ public void applyPreEnrichment( public void applyPostEnrichment( @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { final boolean isBackgroundAnr = isBackgroundAnr(rawHint); - setAppForeground(event, !isBackgroundAnr); + + if (options.isEnableAnrProfiling()) { + applyAnrProfile(event, hint, isBackgroundAnr); + } + setDefaultAnrFingerprint(event, isBackgroundAnr); + + // Set app foreground state + setAppForeground(event, !isBackgroundAnr); } private void setDefaultAnrFingerprint( @@ -710,6 +731,14 @@ private void setDefaultAnrFingerprint( // so we're doing this on the SDK side to group background and foreground ANRs separately // even if they have similar stacktraces. if (event.getFingerprints() == null) { + if (options.isEnableAnrProfiling() && hasOnlySystemFrames(event)) { + // If profiling did not identify any app frames, we want to statically group these events + // to avoid ANR noise due to {{ default }} stacktrace grouping + event.setFingerprints( + Arrays.asList( + "system-frames-only-anr", isBackgroundAnr ? "background-anr" : "foreground-anr")); + return; + } event.setFingerprints( Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); } @@ -777,5 +806,136 @@ private void setAnrExceptions( event.setExceptions( sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); } + + private void applyAnrProfile( + @NotNull SentryEvent event, @NotNull Backfillable hint, boolean isBackgroundAnr) { + + // Skip background ANRs (profiling only runs in foreground) + if (isBackgroundAnr) { + return; + } + + // Get timestamp from hint (ANR hints implement AbnormalExit which has timestamp()) + if (!(hint instanceof AbnormalExit)) { + return; + } + final Long anrTimestampObj = ((AbnormalExit) hint).timestamp(); + final long anrTimestamp; + if (anrTimestampObj != null) { + anrTimestamp = anrTimestampObj; + } else { + anrTimestamp = event.getTimestamp().getTime(); + } + + // Read profile from disk + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final File cacheDir = new File(cacheDirPath); + + 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) { + return; + } + + // Validate timestamp + options.getLogger().log(SentryLevel.INFO, "ANR profile found"); + if (anrTimestamp < anrProfile.startTimeMs || anrTimestamp > anrProfile.endtimeMs) { + options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match"); + return; + } + + // Identify culprit + final AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); + if (culprit == null) { + return; + } + + // Capture profile chunk + final SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + + // Set exceptions with culprit + final StackTraceElement[] stack = culprit.getStack(); + if (stack.length > 0) { + final StackTraceElement stackTraceElement = stack[0]; + final String message = + stackTraceElement.getClassName() + "." + stackTraceElement.getMethodName(); + final AnrException exception = new AnrException(message); + exception.setStackTrace(stack); + + event.setExceptions(sentryExceptionFactory.getSentryExceptions(exception)); + + if (profilerId != null) { + event.getContexts().setProfile(new ProfileContext(profilerId)); + } + } + } + + @Nullable + private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfile anrProfile) { + final SentryProfile profile = StackTraceConverter.convert(anrProfile); + final ProfileChunk chunk = + new ProfileChunk( + new SentryId(), + new SentryId(), + null, + new HashMap<>(0), + anrTimestampMs / 1000.0d, + ProfileChunk.PLATFORM_JAVA, + options); + chunk.setSentryProfile(profile); + + final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); + if (SentryId.EMPTY_ID.equals(profilerId)) { + return null; + } else { + return chunk.getProfilerId(); + } + } + + private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { + final List exceptions = event.getExceptions(); + if (exceptions == null || exceptions.isEmpty()) { + return false; + } + + for (final SentryException exception : exceptions) { + final SentryStackTrace stacktrace = exception.getStacktrace(); + if (stacktrace != null) { + final List frames = stacktrace.getFrames(); + if (frames != null && !frames.isEmpty()) { + for (final SentryStackFrame frame : frames) { + if (frame.isInApp() != null && frame.isInApp()) { + return false; + } + final String module = frame.getModule(); + if (module != null && !AnrCulpritIdentifier.isSystemFrame(module)) { + return false; + } + } + } + } + } + return true; + } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index 7bc354c5181..abd27b51560 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -6,17 +6,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint import io.sentry.SentryEvent import io.sentry.android.core.AnrV2Integration.AnrV2Hint -import io.sentry.android.core.anr.AnrProfileManager -import io.sentry.android.core.anr.AnrProfileRotationHelper -import io.sentry.android.core.anr.AnrStackTrace import io.sentry.android.core.cache.AndroidEnvelopeCache -import io.sentry.protocol.SentryId import io.sentry.util.HintUtils import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull import org.junit.After import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -245,113 +240,4 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } - - @Test - fun `when ANR has only system frames, static fingerprint is set`() { - fixture.options.dsn = "https://key@sentry.io/proj" - fixture.options.isEnableAnrProfiling = true - val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) - - val stack = - arrayOf( - StackTraceElement("android.view.Choreographer", "doFrame", "Choreographer.java", 1234), - StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), - ) - - val profileManager = - AnrProfileManager( - fixture.options, - AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), - ) - profileManager.add(AnrStackTrace(newTimestamp, stack)) - profileManager.close() - AnrProfileRotationHelper.rotate() - - fixture.addAppExitInfo( - timestamp = newTimestamp, - importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, - ) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent( - check { - assertNotNull(it.fingerprints) - assertEquals(2, it.fingerprints!!.size) - assertEquals("{{ system-frames-only-anr }}", it.fingerprints!![0]) - assertEquals("foreground-anr", it.fingerprints!![1]) - }, - anyOrNull(), - ) - } - - @Test - fun `when ANR has app frames, static fingerprints are not set`() { - fixture.options.dsn = "https://key@sentry.io/proj" - fixture.options.isEnableAnrProfiling = true - val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) - - val stack = - arrayOf( - StackTraceElement("com.example.MyApp", "onCreate", "MyApp.java", 1234), - StackTraceElement("android.view.Choreographer", "doFrame", "Choreographer.java", 1234), - StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), - ) - - val profileManager = - AnrProfileManager( - fixture.options, - AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), - ) - profileManager.add(AnrStackTrace(newTimestamp, stack)) - profileManager.close() - AnrProfileRotationHelper.rotate() - - fixture.addAppExitInfo( - timestamp = newTimestamp, - importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND, - ) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes).captureEvent(check { assertNull(it.fingerprints) }, anyOrNull()) - } - - @Test - fun `when ANR profiling is disabled, does not set custom fingerprint`() { - fixture.options.isEnableAnrProfiling = false - val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent(check { assertEquals(null, it.fingerprints) }, anyOrNull()) - } - - @Test - fun `when captureProfileChunk returns empty ID, does not set profile context`() { - fixture.options.isEnableAnrProfiling = true - val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - whenever(fixture.scopes.captureProfileChunk(any())).thenReturn(SentryId.EMPTY_ID) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes) - .captureEvent(check { assertEquals(null, it.contexts.profile) }, anyOrNull()) - } - - @Test - fun `when cacheDirPath is null, does not apply ANR profile`() { - fixture.options.isEnableAnrProfiling = true - fixture.options.cacheDirPath = null - val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) - fixture.addAppExitInfo(timestamp = newTimestamp) - - integration.register(fixture.scopes, fixture.options) - - verify(fixture.scopes, never()).captureProfileChunk(any()) - } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 66090fc815e..0d012652ac1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -8,13 +8,18 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.Hint +import io.sentry.IScopes import io.sentry.IpAddressUtils import io.sentry.NoOpLogger +import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext +import io.sentry.android.core.anr.AnrProfileManager +import io.sentry.android.core.anr.AnrProfileRotationHelper +import io.sentry.android.core.anr.AnrStackTrace import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE @@ -67,6 +72,8 @@ import kotlin.test.assertTrue import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -628,6 +635,181 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.fingerprints) } + @Test + fun `sets system-frames-only fingerprint when ANR profiling enabled and no app frames`() { + fixture.options.isEnableAnrProfiling = true + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "java.lang" + filename = "Thread.java" + function = "run" + } + ) + } + } + ) + } + + assertEquals(listOf("system-frames-only-anr", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `does not set system-frames-only fingerprint when ANR profiling is disabled but no app frames are present`() { + fixture.options.isEnableAnrProfiling = false + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "java.lang" + filename = "Thread.java" + function = "run" + } + ) + } + } + ) + } + + assertEquals(listOf("{{ default }}", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `sets default fingerprint when ANR profiling enabled and app frames are present`() { + fixture.options.isEnableAnrProfiling = true + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "com.example.MyApp" + function = "onCreate" + } + ) + } + } + ) + } + + assertEquals(listOf("{{ default }}", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `does not set profile context when ANR profiling is disabled`() { + fixture.options.isEnableAnrProfiling = false + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "com.example.MyApp" + function = "onCreate" + } + ) + } + } + ) + } + assertNull(processed.contexts.profile) + } + + @Test + fun `applies ANR profile if available`() { + fixture.options.isEnableAnrProfiling = true + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + System.currentTimeMillis(), + arrayOf( + StackTraceElement( + "android.view.Choreographer", + "doFrame", + "Choreographer.java", + 1234, + ), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val processed = processor.process(SentryEvent(), hint) + + assertNotNull(processed?.contexts?.profile) + assertNotNull(processed.contexts.profile?.profilerId) + } + } + + @Test + fun `does not crash when ANR profiling is enabled but cache dir is null`() { + fixture.options.isEnableAnrProfiling = true + fixture.options.cacheDirPath = null + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val original = SentryEvent() + + val processor = fixture.getSut(tmpDir) + val processed = processor.process(original, hint) + + assertNotNull(processed) + assertNull(processed.contexts.profile) + } + @Test fun `sets replayId when replay folder exists`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) From e68c4cf63dd318c99eca817b68d3ce23b907fb39 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 11 Feb 2026 11:32:35 +0100 Subject: [PATCH 13/23] Update changelog --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fef956f1e0..c45fcbe2eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Features - Add `installGroupsOverride` parameter to Build Distribution SDK for programmatic filtering, with support for configuration via properties file using `io.sentry.distribution.install-groups-override` ([#5066](https://github.com/getsentry/sentry-java/pull/5066)) +- 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: `` ### Dependencies @@ -108,11 +113,6 @@ ### 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: `` - Add a Tombstone integration that detects native crashes without relying on the NDK integration, but instead using `ApplicationExitInfo.REASON_CRASH_NATIVE` on Android 12+. ([#4933](https://github.com/getsentry/sentry-java/pull/4933)) - Currently exposed via options as an _internal_ API only. - If enabled alongside the NDK integration, crashes will be reported as two separate events. Users should enable only one; deduplication between both integrations will be added in a future release. From e92a82b7acdb389a84afa7156ed22b21f797fb63 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 11 Feb 2026 11:59:08 +0100 Subject: [PATCH 14/23] Ensure integration is tracked --- .../io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index f1667f00a54..7e8235c3115 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -3,6 +3,7 @@ package io.sentry.android.core.anr import android.os.SystemClock import io.sentry.ILogger import io.sentry.IScopes +import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions @@ -244,6 +245,7 @@ class AnrProfilingIntegrationTest { integration.register(mockScopes, androidOptions) assertFalse(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("AnrProfiling")) integration.close() } From 6ecd31e1a848ccde150256081d887471fe93e285 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 11 Feb 2026 12:32:48 +0100 Subject: [PATCH 15/23] Address PR feedback --- .../sentry/android/core/AnrV2Integration.java | 10 ++----- .../ApplicationExitInfoEventProcessor.java | 28 ++++++++++--------- .../core/anr/AnrProfilingIntegration.java | 6 +--- .../android/core/anr/StackTraceConverter.java | 1 - 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 4797369ffa8..af3a942c8cc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -86,11 +86,7 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { .getExecutorService() .submit( new ApplicationExitInfoHistoryDispatcher( - context, - scopes, - this.options, - dateProvider, - new AnrV2Policy(scopes, this.options))); + context, scopes, this.options, dateProvider, new AnrV2Policy(this.options))); } catch (Throwable e) { options.getLogger().log(SentryLevel.DEBUG, "Failed to start ANR processor.", e); } @@ -109,11 +105,9 @@ public void close() throws IOException { private static final class AnrV2Policy implements ApplicationExitInfoHistoryDispatcher.ApplicationExitInfoPolicy { - private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; - AnrV2Policy(final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { - this.scopes = scopes; + AnrV2Policy(final @NotNull SentryAndroidOptions options) { this.options = options; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 27e891012a0..64b727a20f0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -810,12 +810,17 @@ private void setAnrExceptions( private void applyAnrProfile( @NotNull SentryEvent event, @NotNull Backfillable hint, boolean isBackgroundAnr) { - // Skip background ANRs (profiling only runs in foreground) + // Skip background ANRs (as profiling only runs in foreground) if (isBackgroundAnr) { return; } - // Get timestamp from hint (ANR hints implement AbnormalExit which has timestamp()) + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final File cacheDir = new File(cacheDirPath); + if (!(hint instanceof AbnormalExit)) { return; } @@ -827,13 +832,6 @@ private void applyAnrProfile( anrTimestamp = event.getTimestamp().getTime(); } - // Read profile from disk - final String cacheDirPath = options.getCacheDirPath(); - if (cacheDirPath == null) { - return; - } - final File cacheDir = new File(cacheDirPath); - AnrProfile anrProfile = null; try { final File lastFile = AnrProfileRotationHelper.getLastFile(cacheDir); @@ -857,14 +855,12 @@ private void applyAnrProfile( return; } - // Validate timestamp options.getLogger().log(SentryLevel.INFO, "ANR profile found"); if (anrTimestamp < anrProfile.startTimeMs || anrTimestamp > anrProfile.endtimeMs) { options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match"); return; } - // Identify culprit final AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); if (culprit == null) { return; @@ -873,7 +869,6 @@ private void applyAnrProfile( // Capture profile chunk final SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); - // Set exceptions with culprit final StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { final StackTraceElement stackTraceElement = stack[0]; @@ -882,7 +877,14 @@ private void applyAnrProfile( final AnrException exception = new AnrException(message); exception.setStackTrace(stack); - event.setExceptions(sentryExceptionFactory.getSentryExceptions(exception)); + final @NotNull List sentryException = + sentryExceptionFactory.getSentryExceptions(exception); + for (final @NotNull SentryException e : sentryException) { + final Mechanism mechanism = new Mechanism(); + mechanism.setType("ANR"); + e.setMechanism(mechanism); + } + event.setExceptions(sentryException); if (profilerId != null) { event.getContexts().setProfile(new ProfileContext(profilerId)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 4f99c996607..7c77299f626 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -54,10 +54,6 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions "SentryAndroidOptions is required"); this.logger = options.getLogger(); - if (this.options == null) { - return; - } - if (((SentryAndroidOptions) options).isEnableAnrProfiling()) { addIntegrationToSdkVersion("AnrProfiling"); AppState.getInstance().addAppStateListener(this); @@ -151,7 +147,7 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept final long now = SystemClock.uptimeMillis(); final long diff = now - lastMainThreadExecutionTime; - if (diff < 1000) { + if (diff < THRESHOLD_SUSPICION_MS) { mainThreadState = MainThreadState.IDLE; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java index 3344bc3516c..cc3cf86c631 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java @@ -141,7 +141,6 @@ private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElemen frame.setFunction(element.getMethodName()); frame.setModule(element.getClassName()); frame.setLineno(element.getLineNumber() > 0 ? element.getLineNumber() : null); - frame.setInApp(true); if (element.isNativeMethod()) { frame.setNative(true); } From 29b2ff01d543c106f8d8c013ac2997c55f76f492 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 11 Feb 2026 14:02:41 +0100 Subject: [PATCH 16/23] Fix tests --- .../ApplicationExitInfoEventProcessor.java | 20 +-- .../core/anr/AnrStackTraceConverterTest.kt | 140 ++++++++---------- 2 files changed, 75 insertions(+), 85 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 64b727a20f0..a775f0db2a8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -730,15 +730,17 @@ private void setDefaultAnrFingerprint( // sentry does not yet have a capability to provide default server-side fingerprint rules, // so we're doing this on the SDK side to group background and foreground ANRs separately // even if they have similar stacktraces. - if (event.getFingerprints() == null) { - if (options.isEnableAnrProfiling() && hasOnlySystemFrames(event)) { - // If profiling did not identify any app frames, we want to statically group these events - // to avoid ANR noise due to {{ default }} stacktrace grouping - event.setFingerprints( - Arrays.asList( - "system-frames-only-anr", isBackgroundAnr ? "background-anr" : "foreground-anr")); - return; - } + if (event.getFingerprints() != null) { + return; + } + + if (options.isEnableAnrProfiling() && hasOnlySystemFrames(event)) { + // If profiling did not identify any app frames, we want to statically group these events + // to avoid ANR noise due to {{ default }} stacktrace grouping + event.setFingerprints( + Arrays.asList( + "system-frames-only-anr", isBackgroundAnr ? "background-anr" : "foreground-anr")); + } else { event.setFingerprints( Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt index 65e6c7c6370..f48f1674166 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt @@ -6,7 +6,6 @@ import org.junit.Test class AnrStackTraceConverterTest { @Test fun testConvertSimpleStackTrace() { - // Create a simple stack trace val elements = arrayOf( StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42), @@ -14,42 +13,37 @@ class AnrStackTraceConverterTest { ) val anrStackTrace = AnrStackTrace(1000, elements) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(anrStackTrace) - // Convert to profile val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - // Verify profile structure Assert.assertNotNull(profile) - Assert.assertEquals(1, profile.getSamples().size.toLong()) - Assert.assertEquals(2, profile.getFrames().size.toLong()) - Assert.assertEquals(1, profile.getStacks().size.toLong()) - - // Verify frames - val frame0 = profile.getFrames().get(0) - Assert.assertEquals("MyClass.java", frame0.getFilename()) - Assert.assertEquals("method1", frame0.getFunction()) - Assert.assertEquals("com.example.MyClass", frame0.getModule()) - Assert.assertEquals(42, frame0.getLineno()) - - val frame1 = profile.getFrames().get(1) - Assert.assertEquals("AnotherClass.java", frame1.getFilename()) - Assert.assertEquals("method2", frame1.getFunction()) - Assert.assertEquals("com.example.AnotherClass", frame1.getModule()) - Assert.assertEquals(100, frame1.getLineno()) - - // Verify stack - val stack = profile.getStacks().get(0) - Assert.assertEquals(2, stack.size.toLong()) - Assert.assertEquals(0, (stack.get(0) as Int).toLong()) - Assert.assertEquals(1, (stack.get(1) as Int).toLong()) - - // Verify sample - val sample = profile.getSamples().get(0) - Assert.assertEquals(0, sample.getStackId().toLong()) - Assert.assertEquals("0", sample.getThreadId()) - Assert.assertEquals(1.0, sample.getTimestamp(), 0.001) // 1000ms = 1s + Assert.assertEquals(1, profile.samples.size) + Assert.assertEquals(2, profile.frames.size) + Assert.assertEquals(1, profile.stacks.size) + + val frame0 = profile.frames[0] + Assert.assertEquals("MyClass.java", frame0.filename) + Assert.assertEquals("method1", frame0.function) + Assert.assertEquals("com.example.MyClass", frame0.module) + Assert.assertEquals(42, frame0.lineno) + + val frame1 = profile.frames[1] + Assert.assertEquals("AnotherClass.java", frame1.filename) + Assert.assertEquals("method2", frame1.function) + Assert.assertEquals("com.example.AnotherClass", frame1.module) + Assert.assertEquals(100, frame1.lineno) + + val stack = profile.stacks[0] + Assert.assertEquals(2, stack.size) + Assert.assertEquals(0, (stack[0] as Int)) + Assert.assertEquals(1, (stack[1] as Int)) + + val sample = profile.samples[0] + Assert.assertEquals(0, sample.stackId) + Assert.assertEquals("0", sample.threadId) + Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s } @Test @@ -67,7 +61,7 @@ class AnrStackTraceConverterTest { StackTraceElement("com.example.ThirdClass", "method3", "ThirdClass.java", 200), ) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(AnrStackTrace(1000, elements1)) anrStackTraces.add(AnrStackTrace(2000, elements2)) @@ -75,19 +69,19 @@ class AnrStackTraceConverterTest { val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) // Should have 3 frames total (dedup removes duplicate) - Assert.assertEquals(3, profile.getFrames().size.toLong()) + Assert.assertEquals(3, profile.frames.size) // First sample uses stack [0, 1] - val stack1 = profile.getStacks().get(0) - Assert.assertEquals(2, stack1.size.toLong()) - Assert.assertEquals(0, (stack1.get(0) as Int).toLong()) - Assert.assertEquals(1, (stack1.get(1) as Int).toLong()) + val stack1 = profile.stacks[0] + Assert.assertEquals(2, stack1.size) + Assert.assertEquals(0, (stack1[0] as Int)) + Assert.assertEquals(1, (stack1[1] as Int)) // Second sample uses stack [0, 2] (frame 0 reused) - val stack2 = profile.getStacks().get(1) - Assert.assertEquals(2, stack2.size.toLong()) - Assert.assertEquals(0, (stack2.get(0) as Int).toLong()) - Assert.assertEquals(2, (stack2.get(1) as Int).toLong()) + val stack2 = profile.stacks[1] + Assert.assertEquals(2, stack2.size) + Assert.assertEquals(0, (stack2[0] as Int)) + Assert.assertEquals(2, (stack2[1] as Int)) } @Test @@ -99,29 +93,27 @@ class AnrStackTraceConverterTest { StackTraceElement("com.example.AnotherClass", "method2", "AnotherClass.java", 100), ) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(AnrStackTrace(1000, elements)) anrStackTraces.add(AnrStackTrace(2000, elements.clone())) - // Convert to profile val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) // Should have 2 frames and 1 stack (dedup stack) - Assert.assertEquals(2, profile.getFrames().size.toLong()) - Assert.assertEquals(1, profile.getStacks().size.toLong()) + Assert.assertEquals(2, profile.frames.size) + Assert.assertEquals(1, profile.stacks.size) // Both samples should reference the same stack - Assert.assertEquals(0, profile.getSamples().get(0).getStackId().toLong()) - Assert.assertEquals(0, profile.getSamples().get(1).getStackId().toLong()) + Assert.assertEquals(0, profile.samples[0].stackId) + Assert.assertEquals(0, profile.samples[1].stackId) } @Test fun testTimestampConversion() { val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) - // Test various timestamps val timestampsMs = longArrayOf(1000, 1500, 5000) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() for (ts in timestampsMs) { anrStackTraces.add(AnrStackTrace(ts, elements)) @@ -129,23 +121,21 @@ class AnrStackTraceConverterTest { val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - // Verify timestamps are converted from ms to seconds - Assert.assertEquals(1.0, profile.getSamples().get(0).getTimestamp(), 0.001) - Assert.assertEquals(1.5, profile.getSamples().get(1).getTimestamp(), 0.001) - Assert.assertEquals(5.0, profile.getSamples().get(2).getTimestamp(), 0.001) + Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001) + Assert.assertEquals(1.5, profile.samples[1].timestamp, 0.001) + Assert.assertEquals(5.0, profile.samples[2].timestamp, 0.001) } @Test fun testNativeMethodHandling() { - // Create a native method stack trace val elements = arrayOf(StackTraceElement("java.lang.System", "doSomething", null, -2)) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(AnrStackTrace(1000, elements)) val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - val frame = profile.getFrames().get(0) + val frame = profile.frames[0] Assert.assertTrue(frame.isNative()!!) } @@ -153,57 +143,55 @@ class AnrStackTraceConverterTest { fun testThreadMetadata() { val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(AnrStackTrace(1000, elements)) val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - // Verify thread metadata - val threadMetadata = profile.getThreadMetadata().get("0") + val threadMetadata = profile.threadMetadata["0"] Assert.assertNotNull(threadMetadata) - Assert.assertEquals("main", threadMetadata!!.getName()) - Assert.assertEquals(Thread.NORM_PRIORITY.toLong(), threadMetadata.getPriority().toLong()) + Assert.assertEquals("main", threadMetadata!!.name) + Assert.assertEquals(Thread.NORM_PRIORITY, threadMetadata.priority) } @Test fun testEmptyStackTraceList() { - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - // Should return empty profile with thread metadata Assert.assertNotNull(profile) - Assert.assertEquals(0, profile.getSamples().size.toLong()) - Assert.assertEquals(0, profile.getFrames().size.toLong()) - Assert.assertEquals(0, profile.getStacks().size.toLong()) - Assert.assertTrue(profile.getThreadMetadata().containsKey("0")) + Assert.assertEquals(0, profile.samples.size) + Assert.assertEquals(0, profile.frames.size) + Assert.assertEquals(0, profile.stacks.size) + Assert.assertTrue(profile.threadMetadata.containsKey("0")) } @Test fun testSampleProperties() { val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(AnrStackTrace(12345, elements)) val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - val sample = profile.getSamples().get(0) - Assert.assertEquals("0", sample.getThreadId()) - Assert.assertEquals(0, sample.getStackId().toLong()) - Assert.assertEquals(12.345, sample.getTimestamp(), 0.001) + val sample = profile.samples[0] + Assert.assertEquals("0", sample.threadId) + Assert.assertEquals(0, sample.stackId) + Assert.assertEquals(12.345, sample.timestamp, 0.001) } @Test fun testInAppFrameFlag() { val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) - val anrStackTraces: MutableList = ArrayList() + val anrStackTraces: MutableList = ArrayList() anrStackTraces.add(AnrStackTrace(1000, elements)) val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - val frame = profile.getFrames().get(0) - Assert.assertTrue(frame.isInApp()!!) + val frame = profile.frames[0] + Assert.assertNull(frame.isInApp()) } } From a15602bdc3cb2c0821ca93488f5d836f721937f7 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 11 Feb 2026 15:26:44 +0100 Subject: [PATCH 17/23] Match manifest property to convention, enable profiling in sample app --- CHANGELOG.md | 2 +- .../java/io/sentry/android/core/ManifestMetadataReader.java | 2 +- .../sentry-samples-android/src/main/AndroidManifest.xml | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c45fcbe2eaf..e96ccfe52df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - 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: `` + - Enable via `options.setEnableAnrProfiling(true)` or Android manifest: `` ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 3aa6c14263c..c7a113a1df0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -170,7 +170,7 @@ final class ManifestMetadataReader { static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url"; - static final String ENABLE_ANR_PROFILING = "io.sentry.anr.enable-profiling"; + static final String ENABLE_ANR_PROFILING = "io.sentry.anr.profiling.enable"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 586095e08c4..d1b1c2ad1db 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -257,8 +257,11 @@ - + From 28c7bfa0754f7b4c36468ad106fc75d1b2905319 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 12 Feb 2026 08:48:01 +0100 Subject: [PATCH 18/23] Add more bound checks and null guards --- .../api/sentry-android-core.api | 2 +- .../ApplicationExitInfoEventProcessor.java | 8 ++- .../sentry/android/core/anr/AnrProfile.java | 6 +- .../android/core/anr/AnrProfileManager.java | 7 ++- .../core/anr/AnrProfilingIntegration.java | 56 ++++++++++++++----- .../android/core/anr/AnrStackTrace.java | 5 ++ .../core/anr/AnrProfilingIntegrationTest.kt | 5 +- .../android/core/anr/AnrStackTraceTest.kt | 30 ++++++++++ 8 files changed, 96 insertions(+), 23 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 599073989d6..f44fe5a1661 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -566,7 +566,7 @@ public class io/sentry/android/core/anr/AnrException : java/lang/Exception { } public class io/sentry/android/core/anr/AnrProfile { - public final field endtimeMs J + public final field endTimeMs J public final field stacks Ljava/util/List; public final field startTimeMs J public fun (Ljava/util/List;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index a775f0db2a8..dd8189cc487 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -830,8 +830,10 @@ private void applyAnrProfile( final long anrTimestamp; if (anrTimestampObj != null) { anrTimestamp = anrTimestampObj; - } else { + } else if (event.getTimestamp() != null) { anrTimestamp = event.getTimestamp().getTime(); + } else { + return; } AnrProfile anrProfile = null; @@ -858,7 +860,7 @@ private void applyAnrProfile( } options.getLogger().log(SentryLevel.INFO, "ANR profile found"); - if (anrTimestamp < anrProfile.startTimeMs || anrTimestamp > anrProfile.endtimeMs) { + if (anrTimestamp < anrProfile.startTimeMs || anrTimestamp > anrProfile.endTimeMs) { options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match"); return; } @@ -886,6 +888,8 @@ private void applyAnrProfile( mechanism.setType("ANR"); e.setMechanism(mechanism); } + // Replaces the original ANR exception with the profile-derived one, + // as we assume the profiling stacktrace is more detailed event.setExceptions(sentryException); if (profilerId != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java index 90a80e0a2dd..6d74e50a0d2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -10,7 +10,7 @@ public class AnrProfile { public final List stacks; public final long startTimeMs; - public final long endtimeMs; + public final long endTimeMs; public AnrProfile(List stacks) { this.stacks = new ArrayList<>(stacks.size()); @@ -25,10 +25,10 @@ public AnrProfile(List stacks) { startTimeMs = this.stacks.get(0).timestampMs; // adding 10s to be less strict around end time - endtimeMs = this.stacks.get(this.stacks.size() - 1).timestampMs + 10_000L; + endTimeMs = this.stacks.get(this.stacks.size() - 1).timestampMs + 10_000L; } else { startTimeMs = 0L; - endtimeMs = 0L; + endTimeMs = 0L; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java index 66c4edcaac4..302d74cf58f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -8,6 +8,7 @@ import io.sentry.SentryOptions; import io.sentry.cache.tape.ObjectQueue; import io.sentry.cache.tape.QueueFile; +import io.sentry.util.Objects; import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.DataInputStream; @@ -28,7 +29,11 @@ public class AnrProfileManager implements Closeable { @NotNull private final ObjectQueue queue; public AnrProfileManager(final @NotNull SentryOptions options) { - this(options, new File(options.getCacheDirPath(), "anr_profile")); + this( + options, + new File( + Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath is required"), + "anr_profile")); } public AnrProfileManager(final @NotNull SentryOptions options, final @NotNull File file) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 7c77299f626..5d77ac0e03f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -20,6 +20,7 @@ import java.io.File; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -31,6 +32,7 @@ public class AnrProfilingIntegration public static final long POLLING_INTERVAL_MS = 66; private static final long THRESHOLD_SUSPICION_MS = 1000; public static final long THRESHOLD_ANR_MS = 4000; + static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); private final AtomicBoolean enabled = new AtomicBoolean(true); private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); @@ -39,6 +41,7 @@ public class AnrProfilingIntegration new AutoClosableReentrantLock(); private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + final AtomicInteger numCollectedStacks = new AtomicInteger(); private volatile MainThreadState mainThreadState = MainThreadState.IDLE; private volatile @Nullable AnrProfileManager profileManager; private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); @@ -54,6 +57,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions "SentryAndroidOptions is required"); this.logger = options.getLogger(); + if (options.getCacheDirPath() == null) { + logger.log(SentryLevel.WARNING, "ANR Profiling is enabled but cacheDirPath is not set"); + return; + } + if (((SentryAndroidOptions) options).isEnableAnrProfiling()) { addIntegrationToSdkVersion("AnrProfiling"); AppState.getInstance().addAppStateListener(this); @@ -71,6 +79,7 @@ public void close() throws IOException { if (p != null) { p.close(); } + profileManager = null; } } @@ -138,7 +147,7 @@ public void run() { } } } catch (Throwable t) { - logger.log(SentryLevel.WARNING, "Failed execute AnrStacktraceIntegration", t); + logger.log(SentryLevel.WARNING, "Failed to execute AnrStacktraceIntegration", t); } } @@ -152,7 +161,9 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept } if (mainThreadState == MainThreadState.IDLE && diff > THRESHOLD_SUSPICION_MS) { - logger.log(SentryLevel.DEBUG, "ANR: main thread is suspicious"); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, "ANR: main thread is suspicious"); + } mainThreadState = MainThreadState.SUSPICIOUS; clearStacks(); } @@ -160,21 +171,30 @@ protected void checkMainThread(final @NotNull Thread mainThread) throws IOExcept // if we are suspicious, we need to collect stack traces if (mainThreadState == MainThreadState.SUSPICIOUS || mainThreadState == MainThreadState.ANR_DETECTED) { - final long start = SystemClock.uptimeMillis(); - final @NotNull AnrStackTrace trace = - new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); - final long duration = SystemClock.uptimeMillis() - start; - logger.log( - SentryLevel.DEBUG, - "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); - - addStackTrace(trace); + if (numCollectedStacks.get() < MAX_NUM_STACKS) { + final long start = SystemClock.uptimeMillis(); + final @NotNull AnrStackTrace trace = + new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); + final long duration = SystemClock.uptimeMillis() - start; + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log( + SentryLevel.DEBUG, + "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + } + addStackTrace(trace); + } else { + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log( + SentryLevel.DEBUG, + "ANR: reached maximum number of collected stack traces, skipping further collection"); + } + } } - // TODO is this still required, - // maybe add stop condition if (mainThreadState == MainThreadState.SUSPICIOUS && diff > THRESHOLD_ANR_MS) { - logger.log(SentryLevel.DEBUG, "ANR: main thread ANR threshold reached"); + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, "ANR: main thread ANR threshold reached"); + } mainThreadState = MainThreadState.ANR_DETECTED; } } @@ -192,8 +212,12 @@ protected AnrProfileManager getProfileManager() { if (profileManager == null) { final @NotNull SentryOptions opts = Objects.requireNonNull(options, "Options can't be null"); + final @Nullable String cacheDirPath = opts.getCacheDirPath(); + if (cacheDirPath == null) { + throw new IllegalStateException("cacheDirPath is required for ANR profiling"); + } final @NotNull File currentFile = - AnrProfileRotationHelper.getFileForRecording(new File(opts.getCacheDirPath())); + AnrProfileRotationHelper.getFileForRecording(new File(cacheDirPath)); profileManager = new AnrProfileManager(opts, currentFile); } @@ -202,10 +226,12 @@ protected AnrProfileManager getProfileManager() { } private void clearStacks() throws IOException { + numCollectedStacks.set(0); getProfileManager().clear(); } private void addStackTrace(@NotNull final AnrStackTrace trace) throws IOException { + numCollectedStacks.incrementAndGet(); getProfileManager().add(trace); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java index 64d486cf52b..2d165c501bf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java @@ -12,6 +12,8 @@ @ApiStatus.Internal public final class AnrStackTrace implements Comparable { + private static final int MAX_STACK_LENGTH = 1000; + public final StackTraceElement[] stack; public final long timestampMs; @@ -47,6 +49,9 @@ public static AnrStackTrace deserialize(final @NotNull DataInputStream dis) thro if (version == 1) { final long timestampMs = dis.readLong(); final int stackLength = dis.readInt(); + if (stackLength < 0 || stackLength > MAX_STACK_LENGTH) { + return null; + } final @NotNull StackTraceElement[] stack = new StackTraceElement[stackLength]; for (int i = 0; i < stackLength; i++) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 7e8235c3115..d5c442cac18 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -186,7 +186,10 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MainThreadState.ANR_DETECTED, integration.state) assertEquals(2, integration.profileManager.load().stacks.size) - integration.close() + for (i in 0 until AnrProfilingIntegration.MAX_NUM_STACKS + 1) { + integration.checkMainThread(mainThread) + } + assertEquals(AnrProfilingIntegration.MAX_NUM_STACKS, integration.numCollectedStacks.get()) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt index 56ff85ee0d5..e53ad507bc1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt @@ -94,4 +94,34 @@ class AnrStackTraceTest { assertNull(deserialized.stack[0].fileName) assertEquals(42, deserialized.stack[1].lineNumber) } + + @Test + fun `deserialize returns null for oversized stack length`() { + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + dos.writeShort(1) // version + dos.writeLong(1234567890L) // timestamp + dos.writeInt(1001) // stackLength exceeds MAX_STACK_LENGTH + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNull(deserialized) + } + + @Test + fun `deserialize returns null for negative stack length`() { + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + dos.writeShort(1) // version + dos.writeLong(1234567890L) // timestamp + dos.writeInt(-1) // negative stackLength + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNull(deserialized) + } } From aa79a11da3570e3f1734e0df333183b9fffd4416 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 12 Feb 2026 08:51:45 +0100 Subject: [PATCH 19/23] Remove outdated meta-data --- .../sentry-samples-android/src/main/AndroidManifest.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 2dca7381ae1..b1bdb168ec5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -198,10 +198,6 @@ - - Date: Thu, 12 Feb 2026 08:59:32 +0100 Subject: [PATCH 20/23] Properly handle foreground transitions --- .../core/anr/AnrProfilingIntegration.java | 1 + .../core/anr/AnrProfilingIntegrationTest.kt | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 5d77ac0e03f..63b17c8bd50 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -93,6 +93,7 @@ public void onForeground() { return; } inForeground = true; + updater.run(); final @Nullable Thread oldThread = thread; if (oldThread != null) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index d5c442cac18..fe638cce566 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -192,6 +192,30 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MAX_NUM_STACKS, integration.numCollectedStacks.get()) } + @Test + fun `background foreground transitions don't trigger an ANR`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tempDir.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + integration.onBackground() + + SystemClock.setCurrentTimeMillis(20_000) + integration.onForeground() + + Thread.sleep(100) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + } + @Test fun `does not register when options is not SentryAndroidOptions`() { val plainOptions = From aeb7d72465b654a8f8154f1adf5a190f8f5162c4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 12 Feb 2026 18:51:56 +0100 Subject: [PATCH 21/23] Address PR comments --- .../android/core/anr/AnrProfileManagerTest.kt | 16 ++---- .../core/anr/AnrProfileRotationHelperTest.kt | 50 ++++++++----------- .../core/anr/AnrProfilingIntegrationTest.kt | 28 +++++------ .../distribution/UpdateResponseParserTest.kt | 4 +- 4 files changed, 40 insertions(+), 58 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt index 8c8c36505be..09ee720decc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt @@ -2,28 +2,20 @@ package io.sentry.android.core.anr import io.sentry.SentryOptions import java.io.File -import java.nio.file.Files -import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.mock class AnrProfileManagerTest { - private lateinit var tempDir: File - - @AfterTest - fun cleanup() { - if (::tempDir.isInitialized && tempDir.exists()) { - tempDir.deleteRecursively() - } - } + @get:Rule val tmpDir = TemporaryFolder() private fun createOptions(): SentryOptions { - tempDir = Files.createTempDirectory("anr_profile_test").toFile() val options = SentryOptions() - options.cacheDirPath = tempDir.absolutePath + options.cacheDirPath = tmpDir.newFolder().absolutePath options.setLogger(mock()) return options } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt index 1cc183d8931..0d7d9556111 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt @@ -1,60 +1,52 @@ package io.sentry.android.core.anr import java.io.File -import java.nio.file.Files -import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder class AnrProfileRotationHelperTest { - private lateinit var tempDir: File + @get:Rule val tmpDir = TemporaryFolder() @BeforeTest fun setup() { - tempDir = Files.createTempDirectory("anr_profile_rotation_test").toFile() AnrProfileRotationHelper.rotate() } - @AfterTest - fun cleanup() { - if (::tempDir.isInitialized && tempDir.exists()) { - tempDir.deleteRecursively() - } - } - @Test fun `getFileForRecording returns file with correct name`() { - val file = AnrProfileRotationHelper.getFileForRecording(tempDir) + val file = AnrProfileRotationHelper.getFileForRecording(tmpDir.root) assertEquals("anr_profile", file.name) - assertEquals(tempDir, file.parentFile) + assertEquals(tmpDir.root, file.parentFile) } @Test fun `getLastFile returns last file`() { - val file = AnrProfileRotationHelper.getLastFile(tempDir) + val file = AnrProfileRotationHelper.getLastFile(tmpDir.root) assertEquals("anr_profile_old", file.name) - assertEquals(tempDir, file.parentFile) + assertEquals(tmpDir.root, file.parentFile) } @Test fun `deleteLastFile returns true when file does not exist`() { - val result = AnrProfileRotationHelper.deleteLastFile(tempDir) + val result = AnrProfileRotationHelper.deleteLastFile(tmpDir.root) assertTrue(result) } @Test fun `deleteLastFile returns true when file is deleted successfully`() { - val lastFile = File(tempDir, "anr_profile_old") + val lastFile = File(tmpDir.root, "anr_profile_old") lastFile.writeText("test content") assertTrue(lastFile.exists()) - val result = AnrProfileRotationHelper.deleteLastFile(tempDir) + val result = AnrProfileRotationHelper.deleteLastFile(tmpDir.root) assertTrue(result) assertFalse(lastFile.exists()) @@ -62,10 +54,10 @@ class AnrProfileRotationHelperTest { @Test fun `rotate moves current file to last file`() { - val currentFile = File(tempDir, "anr_profile") + val currentFile = File(tmpDir.root, "anr_profile") currentFile.writeText("current content") - val lastFile = AnrProfileRotationHelper.getLastFile(tempDir) + val lastFile = AnrProfileRotationHelper.getLastFile(tmpDir.root) assertTrue(lastFile.exists()) assertEquals("current content", lastFile.readText()) @@ -73,8 +65,8 @@ class AnrProfileRotationHelperTest { @Test fun `rotate deletes existing last file before moving`() { - val currentFile = File(tempDir, "anr_profile") - val lastFile = File(tempDir, "anr_profile_old") + val currentFile = File(tmpDir.root, "anr_profile") + val lastFile = File(tmpDir.root, "anr_profile_old") lastFile.writeText("last content") currentFile.writeText("current content") @@ -82,7 +74,7 @@ class AnrProfileRotationHelperTest { assertTrue(lastFile.exists()) assertTrue(currentFile.exists()) - val newLastFile = AnrProfileRotationHelper.getLastFile(tempDir) + val newLastFile = AnrProfileRotationHelper.getLastFile(tmpDir.root) assertTrue(newLastFile.exists()) assertEquals("current content", newLastFile.readText()) @@ -90,10 +82,10 @@ class AnrProfileRotationHelperTest { @Test fun `rotate does not directly perform file renaming`() { - val currentFile = File(tempDir, "anr_profile") + val currentFile = File(tmpDir.root, "anr_profile") currentFile.writeText("current") - val lastFile = File(tempDir, "anr_profile_old") + val lastFile = File(tmpDir.root, "anr_profile_old") lastFile.writeText("last") AnrProfileRotationHelper.rotate() @@ -103,18 +95,18 @@ class AnrProfileRotationHelperTest { assertEquals("last", lastFile.readText()) // but once rotated, the last file should now contain the current file's content - AnrProfileRotationHelper.getFileForRecording(tempDir) + AnrProfileRotationHelper.getFileForRecording(tmpDir.root) assertEquals("current", lastFile.readText()) } @Test fun `getFileForRecording triggers rotation when needed`() { - val currentFile = File(tempDir, "anr_profile") + val currentFile = File(tmpDir.root, "anr_profile") currentFile.writeText("content before rotation") - AnrProfileRotationHelper.getFileForRecording(tempDir) + AnrProfileRotationHelper.getFileForRecording(tmpDir.root) - val lastFile = File(tempDir, "anr_profile_old") + val lastFile = File(tmpDir.root, "anr_profile_old") assertTrue(lastFile.exists()) assertEquals("content before rotation", lastFile.readText()) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index fe638cce566..65ec9a45530 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core.anr import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage @@ -8,8 +9,6 @@ import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.SentryAndroidOptions import io.sentry.test.getProperty -import java.io.File -import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -17,25 +16,27 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.kotlin.mock -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class AnrProfilingIntegrationTest { - private lateinit var tempDir: File + + @get:Rule val tmpDir = TemporaryFolder() + private lateinit var mockScopes: IScopes private lateinit var mockLogger: ILogger private lateinit var options: SentryAndroidOptions @BeforeTest fun setup() { - tempDir = Files.createTempDirectory("anr_profile_test").toFile() mockScopes = mock() mockLogger = mock() options = SentryAndroidOptions().apply { - cacheDirPath = tempDir.absolutePath + cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) isEnableAnrProfiling = true } @@ -44,9 +45,6 @@ class AnrProfilingIntegrationTest { @AfterTest fun cleanup() { - if (::tempDir.isInitialized && tempDir.exists()) { - tempDir.deleteRecursively() - } AppState.getInstance().resetInstance() } @@ -163,7 +161,7 @@ class AnrProfilingIntegrationTest { val androidOptions = SentryAndroidOptions().apply { - cacheDirPath = tempDir.absolutePath + cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) isEnableAnrProfiling = true } @@ -199,7 +197,7 @@ class AnrProfilingIntegrationTest { val androidOptions = SentryAndroidOptions().apply { - cacheDirPath = tempDir.absolutePath + cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) isEnableAnrProfiling = true } @@ -220,7 +218,7 @@ class AnrProfilingIntegrationTest { fun `does not register when options is not SentryAndroidOptions`() { val plainOptions = SentryOptions().apply { - cacheDirPath = tempDir.absolutePath + cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) } @@ -243,7 +241,7 @@ class AnrProfilingIntegrationTest { fun `does not register when ANR profiling is disabled`() { val androidOptions = SentryAndroidOptions().apply { - cacheDirPath = tempDir.absolutePath + cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) isEnableAnrProfiling = false } @@ -263,7 +261,7 @@ class AnrProfilingIntegrationTest { fun `registers when ANR profiling is enabled`() { val androidOptions = SentryAndroidOptions().apply { - cacheDirPath = tempDir.absolutePath + cacheDirPath = tmpDir.root.absolutePath setLogger(mockLogger) isEnableAnrProfiling = true } diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt index e15347095f6..89013c430dd 100644 --- a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.distribution +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.SentryOptions import io.sentry.UpdateStatus import org.junit.Assert.assertEquals @@ -7,9 +8,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class UpdateResponseParserTest { private lateinit var options: SentryOptions From 61bb712c05af3146359f9ddf3ec70106ecbf1eec Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 13 Feb 2026 09:35:02 +0100 Subject: [PATCH 22/23] Address PR feedback --- .../api/sentry-android-core.api | 5 ---- .../sentry/android/core/AnrIntegration.java | 11 ++++++-- .../ApplicationExitInfoEventProcessor.java | 25 +++++++++++-------- .../core/ApplicationNotResponding.java | 9 +++++-- .../sentry/android/core/anr/AnrException.java | 15 ----------- .../core/anr/AnrProfilingIntegration.java | 10 ++++---- .../io/sentry/android/core/ANRWatchDogTest.kt | 8 +++--- .../io/sentry/SentryExceptionFactory.java | 4 +-- .../ExceptionMechanismException.java | 11 ++++---- 9 files changed, 47 insertions(+), 51 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index f44fe5a1661..b2234e3bf3f 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -560,11 +560,6 @@ public class io/sentry/android/core/anr/AnrCulpritIdentifier { public static fun isSystemFrame (Ljava/lang/String;)Z } -public class io/sentry/android/core/anr/AnrException : java/lang/Exception { - public fun ()V - public fun (Ljava/lang/String;)V -} - public class io/sentry/android/core/anr/AnrProfile { public final field endTimeMs J public final field stacks Ljava/util/List; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 8243493a50b..6eb39258523 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -139,11 +139,18 @@ void reportANR( message = "Background " + message; } - final ApplicationNotResponding error = new ApplicationNotResponding(message, anr.getThread()); + final @Nullable Thread thread = anr.getThread(); + final @NotNull ApplicationNotResponding error; + if (thread == null) { + error = new ApplicationNotResponding(message); + } else { + error = new ApplicationNotResponding(message, anr.getThread()); + } + final Mechanism mechanism = new Mechanism(); mechanism.setType("ANR"); - return new ExceptionMechanismException(mechanism, error, error.getThread(), true); + return new ExceptionMechanismException(mechanism, error, thread, true); } @TestOnly diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index dd8189cc487..750aea50600 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -41,7 +41,6 @@ import io.sentry.SpanContext; 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; @@ -49,6 +48,7 @@ import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; +import io.sentry.exception.ExceptionMechanismException; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.protocol.App; @@ -871,25 +871,28 @@ private void applyAnrProfile( } // Capture profile chunk - final SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @NotNull StackTraceElement[] stack = culprit.getStack(); - final StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { final StackTraceElement stackTraceElement = stack[0]; final String message = stackTraceElement.getClassName() + "." + stackTraceElement.getMethodName(); - final AnrException exception = new AnrException(message); + final ApplicationNotResponding exception = new ApplicationNotResponding(message); exception.setStackTrace(stack); + final Mechanism mechanism = new Mechanism(); + mechanism.setType("ANR"); + final ExceptionMechanismException error = + new ExceptionMechanismException(mechanism, exception, null, false); + final @NotNull List sentryException = sentryExceptionFactory.getSentryExceptions(exception); - for (final @NotNull SentryException e : sentryException) { - final Mechanism mechanism = new Mechanism(); - mechanism.setType("ANR"); - e.setMechanism(mechanism); - } - // Replaces the original ANR exception with the profile-derived one, - // as we assume the profiling stacktrace is more detailed + + // Replace the original ANR exception with the profile-derived one, + // as we assume the profiling culprit identification is more valuable + // the event threads are kept as-is, so the original main thread stacktrace is still + // available event.setExceptions(sentryException); if (profilerId != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java index f4998240f81..7b21c2e392c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java @@ -17,7 +17,12 @@ final class ApplicationNotResponding extends RuntimeException { private static final long serialVersionUID = 252541144579117016L; - private final @NotNull Thread thread; + private final @Nullable Thread thread; + + ApplicationNotResponding(final @Nullable String message) { + super(message); + this.thread = null; + } ApplicationNotResponding(final @Nullable String message, final @NotNull Thread thread) { super(message); @@ -25,7 +30,7 @@ final class ApplicationNotResponding extends RuntimeException { setStackTrace(this.thread.getStackTrace()); } - public @NotNull Thread getThread() { + public @Nullable Thread getThread() { return thread; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java deleted file mode 100644 index 99ab731e01b..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrException.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.sentry.android.core.anr; - -import org.jetbrains.annotations.ApiStatus; - -@ApiStatus.Internal -public class AnrException extends Exception { - - private static final long serialVersionUID = 8615243433409006646L; - - public AnrException() {} - - public AnrException(String message) { - super(message); - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 63b17c8bd50..f03a9b20fb8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -57,12 +57,12 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions "SentryAndroidOptions is required"); this.logger = options.getLogger(); - if (options.getCacheDirPath() == null) { - logger.log(SentryLevel.WARNING, "ANR Profiling is enabled but cacheDirPath is not set"); - return; - } - if (((SentryAndroidOptions) options).isEnableAnrProfiling()) { + if (options.getCacheDirPath() == null) { + logger.log(SentryLevel.WARNING, "ANR Profiling is enabled but cacheDirPath is not set"); + return; + } + addIntegrationToSdkVersion("AnrProfiling"); AppState.getInstance().addAppStateListener(this); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index c2c3d384c40..2f3b6791c5b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -58,8 +58,8 @@ class ANRWatchDogTest { } while (anr == null && waitCount++ < 100) assertNotNull(anr) - assertEquals(expectedState, anr!!.thread.state) - assertEquals(stacktrace.className, anr!!.stackTrace[0].className) + assertEquals(expectedState, anr.thread!!.state) + assertEquals(stacktrace.className, anr.stackTrace[0].className) } finally { sut.interrupt() es.shutdown() @@ -137,8 +137,8 @@ class ANRWatchDogTest { } while (anr == null && waitCount++ < 100) assertNotNull(anr) - assertEquals(expectedState, anr!!.thread.state) - assertEquals(stacktrace.className, anr!!.stackTrace[0].className) + assertEquals(expectedState, anr.thread!!.state) + assertEquals(stacktrace.className, anr.stackTrace[0].className) } finally { sut.interrupt() es.shutdown() diff --git a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java index 8b2b5709463..cfdce478df6 100644 --- a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java +++ b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java @@ -174,9 +174,9 @@ Deque extractExceptionQueueInternal( final List frames = sentryStackTraceFactory.getStackFrames( currentThrowable.getStackTrace(), includeSentryFrames); + final @Nullable Long threadId = thread != null ? thread.getId() : null; SentryException exception = - getSentryException( - currentThrowable, exceptionMechanism, thread.getId(), frames, snapshot); + getSentryException(currentThrowable, exceptionMechanism, threadId, frames, snapshot); exceptions.addFirst(exception); if (exceptionMechanism.getType() == null) { diff --git a/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java b/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java index 5cfeb747dd2..5df6d473f78 100644 --- a/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java +++ b/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java @@ -4,6 +4,7 @@ import io.sentry.util.Objects; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * A throwable decorator that holds an {@link io.sentry.protocol.Mechanism} related to the decorated @@ -15,7 +16,7 @@ public final class ExceptionMechanismException extends RuntimeException { private final @NotNull Mechanism exceptionMechanism; private final @NotNull Throwable throwable; - private final @NotNull Thread thread; + private final @Nullable Thread thread; private final boolean snapshot; /** @@ -29,11 +30,11 @@ public final class ExceptionMechanismException extends RuntimeException { public ExceptionMechanismException( final @NotNull Mechanism mechanism, final @NotNull Throwable throwable, - final @NotNull Thread thread, + final @Nullable Thread thread, final boolean snapshot) { exceptionMechanism = Objects.requireNonNull(mechanism, "Mechanism is required."); this.throwable = Objects.requireNonNull(throwable, "Throwable is required."); - this.thread = Objects.requireNonNull(thread, "Thread is required."); + this.thread = thread; this.snapshot = snapshot; } @@ -47,7 +48,7 @@ public ExceptionMechanismException( public ExceptionMechanismException( final @NotNull Mechanism mechanism, final @NotNull Throwable throwable, - final @NotNull Thread thread) { + final @Nullable Thread thread) { this(mechanism, throwable, thread, false); } @@ -74,7 +75,7 @@ public ExceptionMechanismException( * * @return the Thread */ - public @NotNull Thread getThread() { + public @Nullable Thread getThread() { return thread; } From 39229d52491e3e66c01fcd415a49634750ca4d39 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 13 Feb 2026 10:59:12 +0100 Subject: [PATCH 23/23] Address PR feedback --- sentry-android-core/api/sentry-android-core.api | 2 +- .../java/io/sentry/android/core/anr/AnrProfile.java | 3 ++- .../sentry/android/core/anr/AnrProfileManager.java | 12 +++++++----- .../android/core/anr/AnrProfileRotationHelper.java | 8 ++++++-- .../android/core/anr/AnrProfilingIntegration.java | 3 +++ .../sentry/android/core/anr/StackTraceConverter.java | 5 +++-- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index b2234e3bf3f..fcbd1fd5f54 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -567,7 +567,7 @@ public class io/sentry/android/core/anr/AnrProfile { public fun (Ljava/util/List;)V } -public class io/sentry/android/core/anr/AnrProfileManager : java/io/Closeable { +public class io/sentry/android/core/anr/AnrProfileManager : java/lang/AutoCloseable { public fun (Lio/sentry/SentryOptions;)V public fun (Lio/sentry/SentryOptions;Ljava/io/File;)V public fun add (Lio/sentry/android/core/anr/AnrStackTrace;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java index 6d74e50a0d2..19999683905 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -4,6 +4,7 @@ import java.util.Collections; import java.util.List; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; @ApiStatus.Internal public class AnrProfile { @@ -12,7 +13,7 @@ public class AnrProfile { public final long startTimeMs; public final long endTimeMs; - public AnrProfile(List stacks) { + public AnrProfile(final @NotNull List stacks) { this.stacks = new ArrayList<>(stacks.size()); for (AnrStackTrace stack : stacks) { if (stack != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java index 302d74cf58f..3d3cc47104c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -10,7 +10,6 @@ import io.sentry.cache.tape.QueueFile; import io.sentry.util.Objects; import java.io.ByteArrayInputStream; -import java.io.Closeable; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.File; @@ -21,7 +20,7 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public class AnrProfileManager implements Closeable { +public class AnrProfileManager implements AutoCloseable { private static final int MAX_NUM_STACKTRACES = (int) ((THRESHOLD_ANR_MS / POLLING_INTERVAL_MS) * 2); @@ -63,6 +62,8 @@ public AnrProfileManager(final @NotNull SentryOptions options, final @NotNull Fi new ObjectQueue.Converter() { @Override public AnrStackTrace from(final byte[] source) throws IOException { + // no need to close the streams since they are backed by byte arrays and don't + // hold any resources final @NotNull ByteArrayInputStream bis = new ByteArrayInputStream(source); final @NotNull DataInputStream dis = new DataInputStream(bis); return AnrStackTrace.deserialize(dis); @@ -72,9 +73,10 @@ public AnrStackTrace from(final byte[] source) throws IOException { public void toStream( final @NotNull AnrStackTrace value, final @NotNull OutputStream sink) throws IOException { - final @NotNull DataOutputStream dos = new DataOutputStream(sink); - value.serialize(dos); - dos.flush(); + try (final @NotNull DataOutputStream dos = new DataOutputStream(sink)) { + value.serialize(dos); + dos.flush(); + } sink.flush(); } }); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java index 98a9ef8fb2b..761309f1670 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java @@ -35,12 +35,16 @@ private static void performRotationIfNeeded(final @NotNull File cacheDir) { final File currentFile = new File(cacheDir, RECORDING_FILE_NAME); final File oldFile = new File(cacheDir, OLD_FILE_NAME); - if (oldFile.exists()) { + try { oldFile.delete(); + } catch (Throwable e) { + // ignored } - if (currentFile.exists()) { + try { currentFile.renameTo(oldFile); + } catch (Throwable e) { + // ignored } shouldRotate.set(false); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index f03a9b20fb8..7cfb5ed6435 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -232,6 +232,9 @@ private void clearStacks() throws IOException { } private void addStackTrace(@NotNull final AnrStackTrace trace) throws IOException { + if (!enabled.get()) { + return; + } numCollectedStacks.incrementAndGet(); getProfileManager().add(trace); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java index cc3cf86c631..f6f29689f72 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java @@ -5,6 +5,7 @@ import io.sentry.protocol.profiling.SentrySample; import io.sentry.protocol.profiling.SentryThreadMetadata; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -86,8 +87,8 @@ public static SentryProfile convert(final @NotNull AnrProfile anrProfile) { threadMetadata.setName(MAIN_THREAD_NAME); threadMetadata.setPriority(Thread.NORM_PRIORITY); - final @NotNull Map threadMetadataMap = new HashMap<>(); - threadMetadataMap.put(MAIN_THREAD_ID, threadMetadata); + final @NotNull Map threadMetadataMap = + Collections.singletonMap(MAIN_THREAD_ID, threadMetadata); profile.setThreadMetadata(threadMetadataMap); return profile;