diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb577a438b..0d723b90c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### 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: `` + ## 8.26.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 1c89d8524c0..96fa8c21150 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,74 @@ 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 : 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; +} + +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 + protected fun checkMainThread (Ljava/lang/Thread;)V + public fun close ()V + protected fun getProfileManager ()Lio/sentry/android/core/anr/AnrProfileManager; + protected fun getState ()Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public fun onBackground ()V + public fun onForeground ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V + public fun run ()V +} + +protected final class io/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState : java/lang/Enum { + public static final field ANR_DETECTED Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static final field IDLE Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static final field SUSPICIOUS Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; + public static fun values ()[Lio/sentry/android/core/anr/AnrProfilingIntegration$MainThreadState; +} + +public final class io/sentry/android/core/anr/AnrStackTrace : java/lang/Comparable { + public final field stack [Ljava/lang/StackTraceElement; + public final field timestampMs J + public fun (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..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 @@ -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,65 @@ 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"); + 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 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]; + 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..001e79725b9 --- /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 + 1; + 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..81f85fedc25 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -0,0 +1,103 @@ +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); + + 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"); + } + + /** + * @param stacks the captured stacktraces + * @return the most common occurring stacktrace identified as the culprit + */ + @Nullable + public static AggregatedStackTrace identify(final @NotNull List stacks) { + if (stacks.isEmpty()) { + return null; + } + + // fold all stacktraces and count their occurrences + 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 < 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; + } + } + + @Nullable AggregatedStackTrace aggregatedStackTrace = stackTraceMap.get(key); + if (aggregatedStackTrace == null) { + aggregatedStackTrace = + new AggregatedStackTrace( + stackTrace.stack, + i, + stackTrace.stack.length - 1, + stackTrace.timestampMs, + quality); + stackTraceMap.put(key, aggregatedStackTrace); + } else { + aggregatedStackTrace.add(stackTrace.timestampMs); + } + } + } + + if (stackTraceMap.isEmpty()) { + return null; + } + + // 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..90a80e0a2dd --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -0,0 +1,34 @@ +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; + + // adding 10s to be less strict around end time + 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..75c58f3ef26 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -0,0 +1,94 @@ +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.Closeable; +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 implements Closeable { + + 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()); + } + + @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 new file mode 100644 index 00000000000..5c7e156a135 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -0,0 +1,207 @@ +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 lifecycleLock = new AutoClosableReentrantLock(); + private final @NotNull AutoClosableReentrantLock profileManagerLock = + 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); + + try (final @NotNull ISentryLifecycleToken ignored = profileManagerLock.acquire()) { + final @Nullable AnrProfileManager p = profileManager; + if (p != null) { + p.close(); + } + } + } + + @Override + public void onForeground() { + if (!enabled.get()) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lifecycleLock.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 = lifecycleLock.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() { + 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; + } + } + } + + 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(".");
This converter handles: + * + *