From b286ad526052f47f8a13a0584082c69a1f72de59 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 12 Nov 2025 20:17:12 +0100 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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