diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd2399e3d7..e96ccfe52df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Features - Add `installGroupsOverride` parameter to Build Distribution SDK for programmatic filtering, with support for configuration via properties file using `io.sentry.distribution.install-groups-override` ([#5066](https://github.com/getsentry/sentry-java/pull/5066)) +- Add new experimental option to capture profiles for ANRs ([#4899](https://github.com/getsentry/sentry-java/pull/4899)) + - This feature will capture a stack profile of the main thread when it gets unresponsive + - The profile gets attached to the ANR event on the next app start, providing a flamegraph of the ANR issue on the sentry issue details page + - Breaking change: if the ANR stacktrace contains only system frames (e.g. `java.lang` or `android.os`), a static fingerprint is set on the ANR event, causing all ANR events to be grouped into a single issue, reducing the overall ANR issue noise + - Enable via `options.setEnableAnrProfiling(true)` or Android manifest: `` ### Dependencies @@ -106,7 +111,7 @@ - Discard envelopes on `4xx` and `5xx` response ([#4950](https://github.com/getsentry/sentry-java/pull/4950)) - This aims to not overwhelm Sentry after an outage or load shedding (including HTTP 429) where too many events are sent at once -### Feature +### Features - Add a Tombstone integration that detects native crashes without relying on the NDK integration, but instead using `ApplicationExitInfo.REASON_CRASH_NATIVE` on Android 12+. ([#4933](https://github.com/getsentry/sentry-java/pull/4933)) - Currently exposed via options as an _internal_ API only. diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 600fe404fb0..fcbd1fd5f54 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -364,6 +364,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isCollectExternalStorageContext ()Z public fun isEnableActivityLifecycleBreadcrumbs ()Z public fun isEnableActivityLifecycleTracingAutoFinish ()Z + public fun isEnableAnrProfiling ()Z public fun isEnableAppComponentBreadcrumbs ()Z public fun isEnableAppLifecycleBreadcrumbs ()Z public fun isEnableAutoActivityLifecycleTracing ()Z @@ -392,6 +393,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 @@ -546,6 +548,79 @@ 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;IIJF)V + public fun addOccurrence (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 static fun isSystemFrame (Ljava/lang/String;)Z +} + +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/lang/AutoCloseable { + public fun (Lio/sentry/SentryOptions;)V + public fun (Lio/sentry/SentryOptions;Ljava/io/File;)V + public fun add (Lio/sentry/android/core/anr/AnrStackTrace;)V + 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 getFileForRecording (Ljava/io/File;)Ljava/io/File; + public static fun getLastFile (Ljava/io/File;)Ljava/io/File; + public static fun rotate ()V +} + +public class io/sentry/android/core/anr/AnrProfilingIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable, java/lang/Runnable { + public static final field POLLING_INTERVAL_MS J + public static final field THRESHOLD_ANR_MS J + public fun ()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_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; 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 de42e13ee23..2025faafa33 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 @@ -26,6 +26,8 @@ import io.sentry.SendFireAndForgetOutboxSender; import io.sentry.SentryLevel; import io.sentry.SentryOpenTelemetryMode; +import io.sentry.android.core.anr.AnrProfileRotationHelper; +import io.sentry.android.core.anr.AnrProfilingIntegration; import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; @@ -140,6 +142,8 @@ static void loadDefaultAndMetadataOptions( .getRuntimeManager() .runWithRelaxedPolicy(() -> getCacheDir(finalContext).getAbsolutePath())); + AnrProfileRotationHelper.rotate(); + readDefaultOptionValues(options, finalContext, buildInfoProvider); AppState.getInstance().registerLifecycleObserver(options); } @@ -400,6 +404,8 @@ static void installDefaultIntegrations( // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); + options.addIntegration(new AnrProfilingIntegration()); + // registerActivityLifecycleCallbacks is only available if Context is an AppContext if (context instanceof Application) { options.addIntegration( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 8243493a50b..6eb39258523 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -139,11 +139,18 @@ void reportANR( message = "Background " + message; } - final ApplicationNotResponding error = new ApplicationNotResponding(message, anr.getThread()); + final @Nullable Thread thread = anr.getThread(); + final @NotNull ApplicationNotResponding error; + if (thread == null) { + error = new ApplicationNotResponding(message); + } else { + error = new ApplicationNotResponding(message, anr.getThread()); + } + final Mechanism mechanism = new Mechanism(); mechanism.setType("ANR"); - return new ExceptionMechanismException(mechanism, error, error.getThread(), true); + return new ExceptionMechanismException(mechanism, error, thread, true); } @TestOnly diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index ec60bd8f128..750aea50600 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -29,6 +29,9 @@ import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IpAddressUtils; +import io.sentry.ProfileChunk; +import io.sentry.ProfileContext; +import io.sentry.Sentry; import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryExceptionFactory; @@ -36,9 +39,16 @@ import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; +import io.sentry.android.core.anr.AggregatedStackTrace; +import io.sentry.android.core.anr.AnrCulpritIdentifier; +import io.sentry.android.core.anr.AnrProfile; +import io.sentry.android.core.anr.AnrProfileManager; +import io.sentry.android.core.anr.AnrProfileRotationHelper; +import io.sentry.android.core.anr.StackTraceConverter; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; +import io.sentry.exception.ExceptionMechanismException; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.protocol.App; @@ -50,10 +60,14 @@ import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.Request; import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.SentryStackTrace; import io.sentry.protocol.SentryThread; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; +import io.sentry.protocol.profiling.SentryProfile; import io.sentry.util.HintUtils; import io.sentry.util.SentryRandom; import java.io.File; @@ -700,8 +714,15 @@ public void applyPreEnrichment( public void applyPostEnrichment( @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { final boolean isBackgroundAnr = isBackgroundAnr(rawHint); - setAppForeground(event, !isBackgroundAnr); + + if (options.isEnableAnrProfiling()) { + applyAnrProfile(event, hint, isBackgroundAnr); + } + setDefaultAnrFingerprint(event, isBackgroundAnr); + + // Set app foreground state + setAppForeground(event, !isBackgroundAnr); } private void setDefaultAnrFingerprint( @@ -709,7 +730,17 @@ private void setDefaultAnrFingerprint( // sentry does not yet have a capability to provide default server-side fingerprint rules, // so we're doing this on the SDK side to group background and foreground ANRs separately // even if they have similar stacktraces. - if (event.getFingerprints() == null) { + if (event.getFingerprints() != null) { + return; + } + + if (options.isEnableAnrProfiling() && hasOnlySystemFrames(event)) { + // If profiling did not identify any app frames, we want to statically group these events + // to avoid ANR noise due to {{ default }} stacktrace grouping + event.setFingerprints( + Arrays.asList( + "system-frames-only-anr", isBackgroundAnr ? "background-anr" : "foreground-anr")); + } else { event.setFingerprints( Arrays.asList("{{ default }}", isBackgroundAnr ? "background-anr" : "foreground-anr")); } @@ -777,5 +808,145 @@ private void setAnrExceptions( event.setExceptions( sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); } + + private void applyAnrProfile( + @NotNull SentryEvent event, @NotNull Backfillable hint, boolean isBackgroundAnr) { + + // Skip background ANRs (as profiling only runs in foreground) + if (isBackgroundAnr) { + return; + } + + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + return; + } + final File cacheDir = new File(cacheDirPath); + + if (!(hint instanceof AbnormalExit)) { + return; + } + final Long anrTimestampObj = ((AbnormalExit) hint).timestamp(); + final long anrTimestamp; + if (anrTimestampObj != null) { + anrTimestamp = anrTimestampObj; + } else if (event.getTimestamp() != null) { + anrTimestamp = event.getTimestamp().getTime(); + } else { + return; + } + + AnrProfile anrProfile = null; + try { + final File lastFile = AnrProfileRotationHelper.getLastFile(cacheDir); + if (lastFile.exists()) { + options.getLogger().log(SentryLevel.DEBUG, "Reading ANR profile"); + try (final AnrProfileManager provider = new AnrProfileManager(options, lastFile)) { + anrProfile = provider.load(); + } + } else { + options.getLogger().log(SentryLevel.DEBUG, "No ANR profile file found"); + } + } catch (Throwable t) { + options.getLogger().log(SentryLevel.INFO, "Could not retrieve ANR profile", t); + } finally { + if (!AnrProfileRotationHelper.deleteLastFile(cacheDir)) { + options.getLogger().log(SentryLevel.INFO, "Could not delete ANR profile file"); + } + } + + if (anrProfile == null) { + return; + } + + options.getLogger().log(SentryLevel.INFO, "ANR profile found"); + if (anrTimestamp < anrProfile.startTimeMs || anrTimestamp > anrProfile.endTimeMs) { + options.getLogger().log(SentryLevel.DEBUG, "ANR profile found, but doesn't match"); + return; + } + + final AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); + if (culprit == null) { + return; + } + + // Capture profile chunk + final @Nullable SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile); + final @NotNull StackTraceElement[] stack = culprit.getStack(); + + if (stack.length > 0) { + final StackTraceElement stackTraceElement = stack[0]; + final String message = + stackTraceElement.getClassName() + "." + stackTraceElement.getMethodName(); + final ApplicationNotResponding exception = new ApplicationNotResponding(message); + exception.setStackTrace(stack); + + final Mechanism mechanism = new Mechanism(); + mechanism.setType("ANR"); + final ExceptionMechanismException error = + new ExceptionMechanismException(mechanism, exception, null, false); + + final @NotNull List sentryException = + sentryExceptionFactory.getSentryExceptions(exception); + + // Replace the original ANR exception with the profile-derived one, + // as we assume the profiling culprit identification is more valuable + // the event threads are kept as-is, so the original main thread stacktrace is still + // available + event.setExceptions(sentryException); + + if (profilerId != null) { + event.getContexts().setProfile(new ProfileContext(profilerId)); + } + } + } + + @Nullable + private SentryId captureAnrProfile(final long anrTimestampMs, @NotNull AnrProfile anrProfile) { + final SentryProfile profile = StackTraceConverter.convert(anrProfile); + final ProfileChunk chunk = + new ProfileChunk( + new SentryId(), + new SentryId(), + null, + new HashMap<>(0), + anrTimestampMs / 1000.0d, + ProfileChunk.PLATFORM_JAVA, + options); + chunk.setSentryProfile(profile); + + final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); + if (SentryId.EMPTY_ID.equals(profilerId)) { + return null; + } else { + return chunk.getProfilerId(); + } + } + + private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { + final List exceptions = event.getExceptions(); + if (exceptions == null || exceptions.isEmpty()) { + return false; + } + + for (final SentryException exception : exceptions) { + final SentryStackTrace stacktrace = exception.getStacktrace(); + if (stacktrace != null) { + final List frames = stacktrace.getFrames(); + if (frames != null && !frames.isEmpty()) { + for (final SentryStackFrame frame : frames) { + if (frame.isInApp() != null && frame.isInApp()) { + return false; + } + final String module = frame.getModule(); + if (module != null && !AnrCulpritIdentifier.isSystemFrame(module)) { + return false; + } + } + } + } + } + return true; + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java index f4998240f81..7b21c2e392c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java @@ -17,7 +17,12 @@ final class ApplicationNotResponding extends RuntimeException { private static final long serialVersionUID = 252541144579117016L; - private final @NotNull Thread thread; + private final @Nullable Thread thread; + + ApplicationNotResponding(final @Nullable String message) { + super(message); + this.thread = null; + } ApplicationNotResponding(final @Nullable String message, final @NotNull Thread thread) { super(message); @@ -25,7 +30,7 @@ final class ApplicationNotResponding extends RuntimeException { setStackTrace(this.thread.getStackTrace()); } - public @NotNull Thread getThread() { + public @Nullable Thread getThread() { return thread; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 17d75b39c74..c7a113a1df0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -170,6 +170,8 @@ final class ManifestMetadataReader { static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url"; + static final String ENABLE_ANR_PROFILING = "io.sentry.anr.profiling.enable"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -659,6 +661,9 @@ static void applyMetadata( if (spotlightUrl != null) { options.setSpotlightConnectionUrl(spotlightUrl); } + + 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 d106f63e75b..e8927939fc1 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 @@ -241,6 +241,8 @@ public interface BeforeCaptureCallback { private @Nullable SentryFrameMetricsCollector frameMetricsCollector; + private boolean enableAnrProfiling = false; + private boolean enableTombstone = false; public SentryAndroidOptions() { @@ -677,6 +679,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..99eeeb7e004 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AggregatedStackTrace.java @@ -0,0 +1,56 @@ +package io.sentry.android.core.anr; + +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 (ratio of app frames: 0.0 to 1.0) + final float 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 occurred + private long startTimeMs; + + // last time the stacktrace occurred + private long endTimeMs; + + public AggregatedStackTrace( + final StackTraceElement[] stack, + final int stackStartIdx, + final int stackEndIdx, + final long timestampMs, + final float 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 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 new file mode 100644 index 00000000000..5b3f955a0f8 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrCulpritIdentifier.java @@ -0,0 +1,159 @@ +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 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"); + } + + 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 a list of stack traces to analyze + * @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 @NotNull AnrStackTrace stackTrace : stacks) { + if (stackTrace.stack.length < 2) { + continue; + } + + // 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 = + new AggregatedStackTrace( + stackTrace.stack, + i, + stackTrace.stack.length - 1, + stackTrace.timestampMs, + quality); + stackTraceMap.put(key, aggregatedStackTrace); + } else { + aggregatedStackTrace.addOccurrence(stackTrace.timestampMs); + } + } + } + + if (stackTraceMap.isEmpty()) { + return null; + } + + // the deepest stacktrace with most count wins + return Collections.max( + stackTraceMap.values(), + (c1, c2) -> + Float.compare(c1.count * c1.quality * c1.depth, c2.count * c2.quality * c2.depth)); + } + + public static boolean isSystemFrame(final @NotNull String clazz) { + for (final String systemPackage : systemAndFrameworkPackages) { + if (clazz.startsWith(systemPackage)) { + return true; + } + } + return false; + } +} 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..19999683905 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfile.java @@ -0,0 +1,35 @@ +package io.sentry.android.core.anr; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class AnrProfile { + public final List stacks; + + public final long startTimeMs; + public final long endTimeMs; + + public AnrProfile(final @NotNull 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..3d3cc47104c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileManager.java @@ -0,0 +1,103 @@ +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 io.sentry.util.Objects; +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 implements AutoCloseable { + + 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) { + this( + options, + new File( + Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath is required"), + "anr_profile")); + } + + public AnrProfileManager(final @NotNull SentryOptions options, final @NotNull File file) { + 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 { + // no need to close the streams since they are backed by byte arrays and don't + // hold any resources + final @NotNull ByteArrayInputStream bis = new ByteArrayInputStream(source); + final @NotNull DataInputStream dis = new DataInputStream(bis); + return AnrStackTrace.deserialize(dis); + } + + @Override + public void toStream( + final @NotNull AnrStackTrace value, final @NotNull OutputStream sink) + throws IOException { + try (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/AnrProfileRotationHelper.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java new file mode 100644 index 00000000000..761309f1670 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfileRotationHelper.java @@ -0,0 +1,73 @@ +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 RECORDING_FILE_NAME = "anr_profile"; + private static final String OLD_FILE_NAME = "anr_profile_old"; + + private static final AtomicBoolean shouldRotate = new AtomicBoolean(true); + 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, RECORDING_FILE_NAME); + final File oldFile = new File(cacheDir, OLD_FILE_NAME); + + try { + oldFile.delete(); + } catch (Throwable e) { + // ignored + } + + try { + currentFile.renameTo(oldFile); + } catch (Throwable e) { + // ignored + } + + shouldRotate.set(false); + } + } + + @NotNull + public static File getFileForRecording(final @NotNull File cacheDir) { + performRotationIfNeeded(cacheDir); + return new File(cacheDir, RECORDING_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); + 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 new file mode 100644 index 00000000000..7cfb5ed6435 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -0,0 +1,247 @@ +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 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.android.core.SentryAndroidOptions; +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 java.util.concurrent.atomic.AtomicInteger; +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; + static final int MAX_NUM_STACKS = (int) (10_000 / POLLING_INTERVAL_MS); + + private final AtomicBoolean enabled = new AtomicBoolean(true); + private final Runnable updater = () -> lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + private final @NotNull AutoClosableReentrantLock lifecycleLock = new AutoClosableReentrantLock(); + private final @NotNull AutoClosableReentrantLock profileManagerLock = + new AutoClosableReentrantLock(); + + private volatile long lastMainThreadExecutionTime = SystemClock.uptimeMillis(); + final AtomicInteger numCollectedStacks = new AtomicInteger(); + private volatile MainThreadState mainThreadState = MainThreadState.IDLE; + private volatile @Nullable AnrProfileManager profileManager; + private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); + private volatile @Nullable SentryAndroidOptions options; + private volatile @Nullable Thread thread = null; + private volatile boolean inForeground = false; + + @Override + 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 (((SentryAndroidOptions) options).isEnableAnrProfiling()) { + if (options.getCacheDirPath() == null) { + logger.log(SentryLevel.WARNING, "ANR Profiling is enabled but cacheDirPath is not set"); + return; + } + + addIntegrationToSdkVersion("AnrProfiling"); + AppState.getInstance().addAppStateListener(this); + } + } + + @Override + 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; + if (p != null) { + p.close(); + } + profileManager = null; + } + } + + @Override + public void onForeground() { + if (!enabled.get()) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lifecycleLock.acquire()) { + if (inForeground) { + return; + } + inForeground = true; + updater.run(); + + final @Nullable Thread oldThread = thread; + if (oldThread != null) { + oldThread.interrupt(); + } + + final @NotNull Thread profilingThread = new Thread(this, "AnrProfilingIntegration"); + profilingThread.start(); + thread = profilingThread; + } + } + + @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 to 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 < THRESHOLD_SUSPICION_MS) { + mainThreadState = MainThreadState.IDLE; + } + + if (mainThreadState == MainThreadState.IDLE && diff > THRESHOLD_SUSPICION_MS) { + if (logger.isEnabled(SentryLevel.DEBUG)) { + 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) { + if (numCollectedStacks.get() < MAX_NUM_STACKS) { + final long start = SystemClock.uptimeMillis(); + final @NotNull AnrStackTrace trace = + new AnrStackTrace(System.currentTimeMillis(), mainThread.getStackTrace()); + final long duration = SystemClock.uptimeMillis() - start; + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log( + SentryLevel.DEBUG, + "AnrWatchdog: capturing main thread stacktrace took " + duration + "ms"); + } + addStackTrace(trace); + } else { + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log( + SentryLevel.DEBUG, + "ANR: reached maximum number of collected stack traces, skipping further collection"); + } + } + } + + if (mainThreadState == MainThreadState.SUSPICIOUS && diff > THRESHOLD_ANR_MS) { + if (logger.isEnabled(SentryLevel.DEBUG)) { + logger.log(SentryLevel.DEBUG, "ANR: main thread ANR threshold reached"); + } + mainThreadState = MainThreadState.ANR_DETECTED; + } + } + + @TestOnly + @NotNull + protected MainThreadState getState() { + return mainThreadState; + } + + @TestOnly + @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 @Nullable String cacheDirPath = opts.getCacheDirPath(); + if (cacheDirPath == null) { + throw new IllegalStateException("cacheDirPath is required for ANR profiling"); + } + final @NotNull File currentFile = + AnrProfileRotationHelper.getFileForRecording(new File(cacheDirPath)); + profileManager = new AnrProfileManager(opts, currentFile); + } + + return profileManager; + } + } + + private void clearStacks() throws IOException { + numCollectedStacks.set(0); + getProfileManager().clear(); + } + + private void addStackTrace(@NotNull final AnrStackTrace trace) throws IOException { + if (!enabled.get()) { + return; + } + numCollectedStacks.incrementAndGet(); + 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..2d165c501bf --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrStackTrace.java @@ -0,0 +1,79 @@ +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 { + + private static final int MAX_STACK_LENGTH = 1000; + + 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); // 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())); + // 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()); + } + } + + @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(); + if (stackLength < 0 || stackLength > MAX_STACK_LENGTH) { + return null; + } + 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(); + // 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); + 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..f6f29689f72 --- /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.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; + +/** + * 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 = + Collections.singletonMap(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); + if (element.isNativeMethod()) { + frame.setNative(true); + } + return frame; + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index c2c3d384c40..2f3b6791c5b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -58,8 +58,8 @@ class ANRWatchDogTest { } while (anr == null && waitCount++ < 100) assertNotNull(anr) - assertEquals(expectedState, anr!!.thread.state) - assertEquals(stacktrace.className, anr!!.stackTrace[0].className) + assertEquals(expectedState, anr.thread!!.state) + assertEquals(stacktrace.className, anr.stackTrace[0].className) } finally { sut.interrupt() es.shutdown() @@ -137,8 +137,8 @@ class ANRWatchDogTest { } while (anr == null && waitCount++ < 100) assertNotNull(anr) - assertEquals(expectedState, anr!!.thread.state) - assertEquals(stacktrace.className, anr!!.stackTrace[0].className) + assertEquals(expectedState, anr.thread!!.state) + assertEquals(stacktrace.className, anr.stackTrace[0].className) } finally { sut.interrupt() es.shutdown() diff --git a/sentry-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 a1a35facf56..abd27b51560 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -8,13 +8,17 @@ import io.sentry.SentryEvent import io.sentry.android.core.AnrV2Integration.AnrV2Hint import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.util.HintUtils +import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import org.junit.After import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check +import org.mockito.kotlin.never import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -159,6 +163,11 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) } + @After + fun cleanup() { + fixture.options.cacheDirPath?.let { File(it).deleteRecursively() } + } + @Test fun `when latest ANR has foreground importance, sets abnormal mechanism to anr_foreground`() { val integration = @@ -211,4 +220,24 @@ class AnrV2IntegrationTest : ApplicationExitIntegrationTestBase() { verify(fixture.scopes).captureEvent(any(), check { assertNotNull(it.threadDump) }) } + + @Test + fun `when traceInputStream is null, does not report ANR`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when traceInputStream has bad data, does not report ANR`() { + val integration = fixture.getSut(tmpDir, lastReportedTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addBadTrace = true) + + integration.register(fixture.scopes, fixture.options) + + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 66090fc815e..0d012652ac1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -8,13 +8,18 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.Hint +import io.sentry.IScopes import io.sentry.IpAddressUtils import io.sentry.NoOpLogger +import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext +import io.sentry.android.core.anr.AnrProfileManager +import io.sentry.android.core.anr.AnrProfileRotationHelper +import io.sentry.android.core.anr.AnrStackTrace import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE @@ -67,6 +72,8 @@ import kotlin.test.assertTrue import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -628,6 +635,181 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.fingerprints) } + @Test + fun `sets system-frames-only fingerprint when ANR profiling enabled and no app frames`() { + fixture.options.isEnableAnrProfiling = true + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "java.lang" + filename = "Thread.java" + function = "run" + } + ) + } + } + ) + } + + assertEquals(listOf("system-frames-only-anr", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `does not set system-frames-only fingerprint when ANR profiling is disabled but no app frames are present`() { + fixture.options.isEnableAnrProfiling = false + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "java.lang" + filename = "Thread.java" + function = "run" + } + ) + } + } + ) + } + + assertEquals(listOf("{{ default }}", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `sets default fingerprint when ANR profiling enabled and app frames are present`() { + fixture.options.isEnableAnrProfiling = true + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "com.example.MyApp" + function = "onCreate" + } + ) + } + } + ) + } + + assertEquals(listOf("{{ default }}", "foreground-anr"), processed.fingerprints) + } + + @Test + fun `does not set profile context when ANR profiling is disabled`() { + fixture.options.isEnableAnrProfiling = false + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val processed = + processEvent(hint, populateScopeCache = false) { + threads = + listOf( + SentryThread().apply { + name = "main" + stacktrace = + SentryStackTrace().apply { + frames = + listOf( + SentryStackFrame().apply { + module = "com.example.MyApp" + function = "onCreate" + } + ) + } + } + ) + } + assertNull(processed.contexts.profile) + } + + @Test + fun `applies ANR profile if available`() { + fixture.options.isEnableAnrProfiling = true + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + add( + AnrStackTrace( + System.currentTimeMillis(), + arrayOf( + StackTraceElement( + "android.view.Choreographer", + "doFrame", + "Choreographer.java", + 1234, + ), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + close() + } + AnrProfileRotationHelper.rotate() + + val scopes = mock() + whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + + mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val processed = processor.process(SentryEvent(), hint) + + assertNotNull(processed?.contexts?.profile) + assertNotNull(processed.contexts.profile?.profilerId) + } + } + + @Test + fun `does not crash when ANR profiling is enabled but cache dir is null`() { + fixture.options.isEnableAnrProfiling = true + fixture.options.cacheDirPath = null + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(mechanism = "anr_foreground")) + val original = SentryEvent() + + val processor = fixture.getSut(tmpDir) + val processed = processor.process(original, hint) + + assertNotNull(processed) + assertNull(processed.contexts.profile) + } + @Test fun `sets replayId when replay folder exists`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt index ff30dfb2e10..649e14e413b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitIntegrationTestBase.kt @@ -418,6 +418,7 @@ abstract class ApplicationExitIntegrationTestBase { extraOptions(this) } options.cacheDirPath?.let { cacheDir -> + File(cacheDir).mkdirs() lastReportedFile = File(cacheDir, config.lastReportedFileName) lastReportedFile.writeText(lastReportedTimestamp.toString()) } 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 36a0a531a64..b0fb7c92552 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 @@ -1972,6 +1972,31 @@ class ManifestMetadataReaderTest { ) } + @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) + } + // Network Detail Configuration Tests @Test 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/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 716d03d8d7a..5ebf4ee88e1 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 @@ -28,6 +28,7 @@ import io.sentry.Session import io.sentry.ShutdownHookIntegration 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 @@ -475,7 +476,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 || @@ -484,6 +485,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 new file mode 100644 index 00000000000..a3023829ea8 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrCulpritIdentifierTest.kt @@ -0,0 +1,172 @@ +package io.sentry.android.core.anr + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AnrCulpritIdentifierTest { + + @Test + fun `returns null for empty dumps`() { + val dumps = emptyList() + val result = AnrCulpritIdentifier.identify(dumps) + assertNull(result) + } + + @Test + fun `identifies single stack trace`() { + 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)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(1, result.count) + assertEquals("com.example.MyClass", result.stack.first().className) + assertEquals(2, result.depth) + } + + @Test + fun `identifies most common, most detailed stack trace from multiple dumps`() { + val commonElements = + arrayOf( + 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.CommonClass", "commonMethod2", "CommonClass.java", 100), + ) + val dumps = + listOf( + AnrStackTrace(1000, commonElements), + AnrStackTrace(2000, commonElements), + AnrStackTrace(3000, rareElements), + ) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + 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`() { + 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), + ) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals("com.example.MyClass", result.stack.first().className) + } + + @Test + 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, deepStack)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + assertEquals(3, result.depth) + assertEquals("com.example.MyClass", result.stack.first().className) + } + + @Test + fun `handles mixed framework and app code`() { + 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)) + + val result = AnrCulpritIdentifier.identify(dumps) + + assertNotNull(result) + 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/AnrProfileManagerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt new file mode 100644 index 00000000000..09ee720decc --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileManagerTest.kt @@ -0,0 +1,137 @@ +package io.sentry.android.core.anr + +import io.sentry.SentryOptions +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock + +class AnrProfileManagerTest { + @get:Rule val tmpDir = TemporaryFolder() + + private fun createOptions(): SentryOptions { + val options = SentryOptions() + options.cacheDirPath = tmpDir.newFolder().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/AnrProfileRotationHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt new file mode 100644 index 00000000000..0d7d9556111 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfileRotationHelperTest.kt @@ -0,0 +1,113 @@ +package io.sentry.android.core.anr + +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +class AnrProfileRotationHelperTest { + @get:Rule val tmpDir = TemporaryFolder() + + @BeforeTest + fun setup() { + AnrProfileRotationHelper.rotate() + } + + @Test + fun `getFileForRecording returns file with correct name`() { + val file = AnrProfileRotationHelper.getFileForRecording(tmpDir.root) + + assertEquals("anr_profile", file.name) + assertEquals(tmpDir.root, file.parentFile) + } + + @Test + fun `getLastFile returns last file`() { + val file = AnrProfileRotationHelper.getLastFile(tmpDir.root) + + assertEquals("anr_profile_old", file.name) + assertEquals(tmpDir.root, file.parentFile) + } + + @Test + fun `deleteLastFile returns true when file does not exist`() { + val result = AnrProfileRotationHelper.deleteLastFile(tmpDir.root) + + assertTrue(result) + } + + @Test + fun `deleteLastFile returns true when file is deleted successfully`() { + val lastFile = File(tmpDir.root, "anr_profile_old") + lastFile.writeText("test content") + assertTrue(lastFile.exists()) + + val result = AnrProfileRotationHelper.deleteLastFile(tmpDir.root) + + assertTrue(result) + assertFalse(lastFile.exists()) + } + + @Test + fun `rotate moves current file to last file`() { + val currentFile = File(tmpDir.root, "anr_profile") + currentFile.writeText("current content") + + val lastFile = AnrProfileRotationHelper.getLastFile(tmpDir.root) + + assertTrue(lastFile.exists()) + assertEquals("current content", lastFile.readText()) + } + + @Test + fun `rotate deletes existing last file before moving`() { + val currentFile = File(tmpDir.root, "anr_profile") + val lastFile = File(tmpDir.root, "anr_profile_old") + + lastFile.writeText("last content") + currentFile.writeText("current content") + + assertTrue(lastFile.exists()) + assertTrue(currentFile.exists()) + + val newLastFile = AnrProfileRotationHelper.getLastFile(tmpDir.root) + + assertTrue(newLastFile.exists()) + assertEquals("current content", newLastFile.readText()) + } + + @Test + fun `rotate does not directly perform file renaming`() { + val currentFile = File(tmpDir.root, "anr_profile") + currentFile.writeText("current") + + val lastFile = File(tmpDir.root, "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(tmpDir.root) + assertEquals("current", lastFile.readText()) + } + + @Test + fun `getFileForRecording triggers rotation when needed`() { + val currentFile = File(tmpDir.root, "anr_profile") + currentFile.writeText("content before rotation") + + AnrProfileRotationHelper.getFileForRecording(tmpDir.root) + + val lastFile = File(tmpDir.root, "anr_profile_old") + assertTrue(lastFile.exists()) + assertEquals("content before rotation", lastFile.readText()) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt new file mode 100644 index 00000000000..65ec9a45530 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -0,0 +1,277 @@ +package io.sentry.android.core.anr + +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.android.core.AppState +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.test.getProperty +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.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.mock + +@RunWith(AndroidJUnit4::class) +class AnrProfilingIntegrationTest { + + @get:Rule val tmpDir = TemporaryFolder() + + private lateinit var mockScopes: IScopes + private lateinit var mockLogger: ILogger + private lateinit var options: SentryAndroidOptions + + @BeforeTest + fun setup() { + mockScopes = mock() + mockLogger = mock() + options = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + AppState.getInstance().resetInstance() + } + + @AfterTest + fun cleanup() { + AppState.getInstance().resetInstance() + } + + @Test + fun `onForeground starts monitoring thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + integration.onForeground() + Thread.sleep(100) // Allow thread to start + + val thread = integration.getProperty("thread") + assertNotNull(thread) + assertTrue(thread.isAlive) + assertEquals("AnrProfilingIntegration", thread.name) + } + + @Test + fun `onBackground stops monitoring thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + Thread.sleep(100) + + val thread = integration.getProperty("thread") + assertNotNull(thread) + + integration.onBackground() + thread.join(2000) // Wait for thread to stop + + assertTrue(!thread.isAlive) + } + + @Test + fun `close disables integration and interrupts thread`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + integration.onForeground() + Thread.sleep(100) + + val thread = integration.getProperty("thread") + assertNotNull(thread) + + assertTrue(AppState.getInstance().lifecycleObserver.listeners.isNotEmpty()) + + integration.close() + thread.join(2000) + + 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`() { + 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`() { + val integration = AnrProfilingIntegration() + integration.register(mockScopes, options) + + integration.onForeground() + Thread.sleep(100) + val thread1 = integration.getProperty("thread") + + integration.onForeground() + Thread.sleep(100) + val thread2 = integration.getProperty("thread") + + 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) + + integration.onForeground() + Thread.sleep(100) + val thread1 = integration.getProperty("thread") + + integration.onBackground() + integration.onForeground() + + Thread.sleep(100) + val thread2 = integration.getProperty("thread") + + 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`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_00) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + integration.onForeground() + + 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) + + for (i in 0 until AnrProfilingIntegration.MAX_NUM_STACKS + 1) { + integration.checkMainThread(mainThread) + } + assertEquals(AnrProfilingIntegration.MAX_NUM_STACKS, integration.numCollectedStacks.get()) + } + + @Test + fun `background foreground transitions don't trigger an ANR`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + integration.onBackground() + + SystemClock.setCurrentTimeMillis(20_000) + integration.onForeground() + + Thread.sleep(100) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + } + + @Test + fun `does not register when options is not SentryAndroidOptions`() { + val plainOptions = + SentryOptions().apply { + cacheDirPath = tmpDir.root.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 = tmpDir.root.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 = tmpDir.root.absolutePath + setLogger(mockLogger) + isEnableAnrProfiling = true + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + assertFalse(AppState.getInstance().lifecycleObserver.listeners.isEmpty()) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("AnrProfiling")) + + 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..f48f1674166 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt @@ -0,0 +1,197 @@ +package io.sentry.android.core.anr + +import org.junit.Assert +import org.junit.Test + +class AnrStackTraceConverterTest { + @Test + fun testConvertSimpleStackTrace() { + 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) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertNotNull(profile) + Assert.assertEquals(1, profile.samples.size) + Assert.assertEquals(2, profile.frames.size) + Assert.assertEquals(1, profile.stacks.size) + + val frame0 = profile.frames[0] + Assert.assertEquals("MyClass.java", frame0.filename) + Assert.assertEquals("method1", frame0.function) + Assert.assertEquals("com.example.MyClass", frame0.module) + Assert.assertEquals(42, frame0.lineno) + + val frame1 = profile.frames[1] + Assert.assertEquals("AnotherClass.java", frame1.filename) + Assert.assertEquals("method2", frame1.function) + Assert.assertEquals("com.example.AnotherClass", frame1.module) + Assert.assertEquals(100, frame1.lineno) + + val stack = profile.stacks[0] + Assert.assertEquals(2, stack.size) + Assert.assertEquals(0, (stack[0] as Int)) + Assert.assertEquals(1, (stack[1] as Int)) + + val sample = profile.samples[0] + Assert.assertEquals(0, sample.stackId) + Assert.assertEquals("0", sample.threadId) + Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s + } + + @Test + 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.frames.size) + + // First sample uses stack [0, 1] + val stack1 = profile.stacks[0] + Assert.assertEquals(2, stack1.size) + Assert.assertEquals(0, (stack1[0] as Int)) + Assert.assertEquals(1, (stack1[1] as Int)) + + // Second sample uses stack [0, 2] (frame 0 reused) + val stack2 = profile.stacks[1] + Assert.assertEquals(2, stack2.size) + Assert.assertEquals(0, (stack2[0] as Int)) + Assert.assertEquals(2, (stack2[1] as Int)) + } + + @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())) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + // Should have 2 frames and 1 stack (dedup stack) + Assert.assertEquals(2, profile.frames.size) + Assert.assertEquals(1, profile.stacks.size) + + // Both samples should reference the same stack + Assert.assertEquals(0, profile.samples[0].stackId) + Assert.assertEquals(0, profile.samples[1].stackId) + } + + @Test + fun testTimestampConversion() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + 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)) + + Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001) + Assert.assertEquals(1.5, profile.samples[1].timestamp, 0.001) + Assert.assertEquals(5.0, profile.samples[2].timestamp, 0.001) + } + + @Test + fun testNativeMethodHandling() { + 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.frames[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)) + + val threadMetadata = profile.threadMetadata["0"] + Assert.assertNotNull(threadMetadata) + Assert.assertEquals("main", threadMetadata!!.name) + Assert.assertEquals(Thread.NORM_PRIORITY, threadMetadata.priority) + } + + @Test + fun testEmptyStackTraceList() { + val anrStackTraces: MutableList = ArrayList() + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + Assert.assertNotNull(profile) + Assert.assertEquals(0, profile.samples.size) + Assert.assertEquals(0, profile.frames.size) + Assert.assertEquals(0, profile.stacks.size) + Assert.assertTrue(profile.threadMetadata.containsKey("0")) + } + + @Test + fun testSampleProperties() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(12345, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val sample = profile.samples[0] + Assert.assertEquals("0", sample.threadId) + Assert.assertEquals(0, sample.stackId) + Assert.assertEquals(12.345, sample.timestamp, 0.001) + } + + @Test + fun testInAppFrameFlag() { + val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) + + val anrStackTraces: MutableList = ArrayList() + anrStackTraces.add(AnrStackTrace(1000, elements)) + + val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) + + val frame = profile.frames[0] + Assert.assertNull(frame.isInApp()) + } +} 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..e53ad507bc1 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceTest.kt @@ -0,0 +1,127 @@ +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) + } + + @Test + fun `deserialize returns null for oversized stack length`() { + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + dos.writeShort(1) // version + dos.writeLong(1234567890L) // timestamp + dos.writeInt(1001) // stackLength exceeds MAX_STACK_LENGTH + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNull(deserialized) + } + + @Test + fun `deserialize returns null for negative stack length`() { + val bytes = ByteArrayOutputStream() + val dos = DataOutputStream(bytes) + dos.writeShort(1) // version + dos.writeLong(1234567890L) // timestamp + dos.writeInt(-1) // negative stackLength + dos.flush() + + val dis = DataInputStream(ByteArrayInputStream(bytes.toByteArray())) + val deserialized = AnrStackTrace.deserialize(dis) + + assertNull(deserialized) + } +} diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt index e15347095f6..89013c430dd 100644 --- a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.distribution +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.SentryOptions import io.sentry.UpdateStatus import org.junit.Assert.assertEquals @@ -7,9 +8,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) +@RunWith(AndroidJUnit4::class) class UpdateResponseParserTest { private lateinit var options: SentryOptions diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 0acecb4ccf3..b1bdb168ec5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -253,11 +253,14 @@ - - + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 70ff460f3b9..82a0b400c01 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7679,6 +7679,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 58d150886d3..dd47d2b99d0 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -302,42 +302,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(); @@ -349,7 +351,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(); + } } }); @@ -358,7 +362,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/SentryExceptionFactory.java b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java index 8b2b5709463..cfdce478df6 100644 --- a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java +++ b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java @@ -174,9 +174,9 @@ Deque extractExceptionQueueInternal( final List frames = sentryStackTraceFactory.getStackFrames( currentThrowable.getStackTrace(), includeSentryFrames); + final @Nullable Long threadId = thread != null ? thread.getId() : null; SentryException exception = - getSentryException( - currentThrowable, exceptionMechanism, thread.getId(), frames, snapshot); + getSentryException(currentThrowable, exceptionMechanism, threadId, frames, snapshot); exceptions.addFirst(exception); if (exceptionMechanism.getType() == null) { diff --git a/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java b/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java index 5cfeb747dd2..5df6d473f78 100644 --- a/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java +++ b/sentry/src/main/java/io/sentry/exception/ExceptionMechanismException.java @@ -4,6 +4,7 @@ import io.sentry.util.Objects; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * A throwable decorator that holds an {@link io.sentry.protocol.Mechanism} related to the decorated @@ -15,7 +16,7 @@ public final class ExceptionMechanismException extends RuntimeException { private final @NotNull Mechanism exceptionMechanism; private final @NotNull Throwable throwable; - private final @NotNull Thread thread; + private final @Nullable Thread thread; private final boolean snapshot; /** @@ -29,11 +30,11 @@ public final class ExceptionMechanismException extends RuntimeException { public ExceptionMechanismException( final @NotNull Mechanism mechanism, final @NotNull Throwable throwable, - final @NotNull Thread thread, + final @Nullable Thread thread, final boolean snapshot) { exceptionMechanism = Objects.requireNonNull(mechanism, "Mechanism is required."); this.throwable = Objects.requireNonNull(throwable, "Throwable is required."); - this.thread = Objects.requireNonNull(thread, "Thread is required."); + this.thread = thread; this.snapshot = snapshot; } @@ -47,7 +48,7 @@ public ExceptionMechanismException( public ExceptionMechanismException( final @NotNull Mechanism mechanism, final @NotNull Throwable throwable, - final @NotNull Thread thread) { + final @Nullable Thread thread) { this(mechanism, throwable, thread, false); } @@ -74,7 +75,7 @@ public ExceptionMechanismException( * * @return the Thread */ - public @NotNull Thread getThread() { + public @Nullable Thread getThread() { return thread; } 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(".");