From 3321ca64f6e6007add32dec27db116d912264662 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 12 Nov 2026 13:32:24 +0100 Subject: [PATCH 1/2] feat(anr): Send ANR profiles as flame graph attachments instead of profile chunks --- .../api/sentry-android-core.api | 5 - .../ApplicationExitInfoEventProcessor.java | 170 ++++-------- .../core/anr/AnrFlameGraphAttachment.java | 27 +- .../android/core/anr/StackTraceConverter.java | 181 ------------- .../ApplicationExitInfoEventProcessorTest.kt | 189 +++++++------ .../core/anr/AnrFlameGraphAttachmentTest.kt | 14 + .../core/anr/AnrStackTraceConverterTest.kt | 249 ------------------ 7 files changed, 209 insertions(+), 626 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 17d9c1edd6d..da1c62dc232 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -773,11 +773,6 @@ public final class io/sentry/android/core/anr/AnrStackTrace : java/lang/Comparab 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/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index a82a6de5db8..008266c67e3 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 @@ -25,13 +25,11 @@ import android.os.Build; import android.util.DisplayMetrics; import androidx.annotation.WorkerThread; +import io.sentry.Attachment; import io.sentry.BackfillingEventProcessor; 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; @@ -39,12 +37,13 @@ import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; +import io.sentry.android.core.AnrV2Integration.AnrV2Hint; import io.sentry.android.core.anr.AggregatedStackTrace; import io.sentry.android.core.anr.AnrCulpritIdentifier; +import io.sentry.android.core.anr.AnrFlameGraphAttachment; 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; @@ -63,13 +62,11 @@ 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; @@ -123,9 +120,9 @@ public ApplicationExitInfoEventProcessor( sentryExceptionFactory = new SentryExceptionFactory(sentryStackTraceFactory); } - private @Nullable HintEnricher findEnricher(final @NotNull Object hint) { + private @Nullable HintEnricher findEnricher(final @NotNull Object sdkHint) { for (HintEnricher enricher : hintEnrichers) { - if (enricher.supports(hint)) { + if (enricher.supports(sdkHint)) { return enricher; } } @@ -143,8 +140,8 @@ public ApplicationExitInfoEventProcessor( @Override public @Nullable SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { - final Object unwrappedHint = HintUtils.getSentrySdkHint(hint); - if (!(unwrappedHint instanceof Backfillable)) { + final @Nullable Object sdkHint = HintUtils.getSentrySdkHint(hint); + if (!(sdkHint instanceof Backfillable)) { options .getLogger() .log( @@ -152,11 +149,11 @@ public ApplicationExitInfoEventProcessor( "The event is not Backfillable, but has been passed to BackfillingEventProcessor, skipping."); return event; } - final @NotNull Backfillable backfillable = (Backfillable) unwrappedHint; - final @Nullable HintEnricher hintEnricher = findEnricher(unwrappedHint); + final @NotNull Backfillable backfillable = (Backfillable) sdkHint; + final @Nullable HintEnricher hintEnricher = findEnricher(sdkHint); if (hintEnricher != null) { - hintEnricher.applyPreEnrichment(event, backfillable, unwrappedHint); + hintEnricher.applyPreEnrichment(event, backfillable); } // We always set os and device even if the ApplicationExitInfo event is not enrich-able. @@ -186,7 +183,7 @@ public ApplicationExitInfoEventProcessor( setStaticValues(event); if (hintEnricher != null) { - hintEnricher.applyPostEnrichment(event, backfillable, unwrappedHint, optionsSource); + hintEnricher.applyPostEnrichment(event, hint, backfillable, optionsSource); } return event; @@ -592,12 +589,12 @@ private void setDist( * accepted for compatibility; {@link OptionsSource#NONE} is returned when neither current nor * persisted options can be matched to the exit. */ - private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable hint) { + private @NotNull OptionsSource getOptionsSource(final @NotNull Backfillable backfillable) { final @Nullable Long timestamp; - if (hint instanceof AbnormalExit) { - timestamp = ((AbnormalExit) hint).timestamp(); - } else if (hint instanceof NativeCrashExit) { - timestamp = ((NativeCrashExit) hint).timestamp(); + if (backfillable instanceof AbnormalExit) { + timestamp = ((AbnormalExit) backfillable).timestamp(); + } else if (backfillable instanceof NativeCrashExit) { + timestamp = ((NativeCrashExit) backfillable).timestamp(); } else { timestamp = null; } @@ -795,53 +792,48 @@ private void mergeOS(final @NotNull SentryBaseEvent event) { } private interface HintEnricher { - boolean supports(@NotNull Object hint); + boolean supports(@NotNull Object sdkHint); - void applyPreEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint); + void applyPreEnrichment(@NotNull SentryEvent event, @NotNull Backfillable backfillable); void applyPostEnrichment( @NotNull SentryEvent event, - @NotNull Backfillable hint, - @NotNull Object rawHint, + @NotNull Hint hint, + @NotNull Backfillable backfillable, @NotNull OptionsSource optionsSource); } private final class AnrHintEnricher implements HintEnricher { @Override - public boolean supports(@NotNull Object hint) { - return hint instanceof AnrV2Integration.AnrV2Hint; + public boolean supports(@NotNull Object sdkHint) { + return sdkHint instanceof AnrV2Hint; } - // by default we assume that the ANR is foreground, unless abnormalMechanism is "anr_background" - private boolean isBackgroundAnr(final @NotNull Object hint) { - if (hint instanceof AbnormalExit) { - final String abnormalMechanism = ((AbnormalExit) hint).mechanism(); - return "anr_background".equals(abnormalMechanism); - } - return false; + private boolean isBackgroundAnr(final @NotNull AnrV2Hint anrHint) { + return "anr_background".equals(anrHint.mechanism()); } @Override - public void applyPreEnrichment( - @NotNull SentryEvent event, @NotNull Backfillable hint, @NotNull Object rawHint) { - final boolean isBackgroundAnr = isBackgroundAnr(rawHint); + public void applyPreEnrichment(@NotNull SentryEvent event, @NotNull Backfillable backfillable) { + final @NotNull AnrV2Hint anrHint = (AnrV2Hint) backfillable; + final boolean isBackgroundAnr = isBackgroundAnr(anrHint); // we always set exception values and default platform even if the ANR is not enrich-able setDefaultPlatform(event); - setAnrExceptions(event, hint, isBackgroundAnr); + setAnrExceptions(event, anrHint, isBackgroundAnr); } @Override public void applyPostEnrichment( @NotNull SentryEvent event, - @NotNull Backfillable hint, - @NotNull Object rawHint, + @NotNull Hint hint, + @NotNull Backfillable backfillable, @NotNull OptionsSource optionsSource) { - final boolean isBackgroundAnr = isBackgroundAnr(rawHint); + final @NotNull AnrV2Hint anrHint = (AnrV2Hint) backfillable; + final boolean isBackgroundAnr = isBackgroundAnr(anrHint); if (options.isAnrProfilingEnabled()) { - applyAnrProfile(event, hint, isBackgroundAnr, optionsSource); + applyAnrProfile(event, hint, anrHint, isBackgroundAnr, optionsSource); } setDefaultAnrFingerprint(event, isBackgroundAnr); @@ -901,7 +893,7 @@ private SentryThread findMainThread(final @Nullable List threads) private void setAnrExceptions( final @NotNull SentryEvent event, - final @NotNull Backfillable hint, + final @NotNull AnrV2Hint anrHint, final boolean isBackgroundAnr) { if (event.getExceptions() != null) { return; @@ -909,7 +901,7 @@ private void setAnrExceptions( // AnrV2 threads contain a thread dump from the OS, so we just search for the main thread dump // and make an exception out of its stacktrace final Mechanism mechanism = new Mechanism(); - if (!hint.shouldEnrich()) { + if (!anrHint.shouldEnrich()) { // we only enrich the latest ANR in the list, so this is historical mechanism.setType("HistoricalAppExitInfo"); } else { @@ -934,10 +926,10 @@ private void setAnrExceptions( sentryExceptionFactory.getSentryExceptionsFromThread(mainThread, mechanism, anr)); } - @SuppressWarnings("JavaUtilDate") private void applyAnrProfile( @NotNull SentryEvent event, - @NotNull Backfillable hint, + @NotNull Hint hint, + @NotNull AnrV2Hint anrHint, boolean isBackgroundAnr, @NotNull OptionsSource optionsSource) { @@ -952,18 +944,7 @@ private void applyAnrProfile( } 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; - } + final long anrTimestamp = anrHint.timestamp(); AnrProfile anrProfile = null; try { @@ -994,14 +975,28 @@ private void applyAnrProfile( return; } + // The profile is read after the relaunch, and the app may have been updated in between. The + // build options select the ProGuard UUID of the build that recorded the profile. + @Nullable + String proguardUuid = + getBuildOption( + PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); + if (proguardUuid == null) { + // Best effort: the frames can only be deobfuscated with a UUID, and the profile is most + // likely from the current build. + proguardUuid = options.getProguardUuid(); + } + final @Nullable Attachment flameGraph = + AnrFlameGraphAttachment.fromAnrProfile(anrProfile, proguardUuid); + if (flameGraph != null) { + hint.addAttachment(flameGraph); + } + final AggregatedStackTrace culprit = AnrCulpritIdentifier.identify(anrProfile.stacks); if (culprit == null) { return; } - // Capture profile chunk - final @Nullable SentryId profilerId = - captureAnrProfile(anrTimestamp, anrProfile, optionsSource); final @NotNull StackTraceElement[] stack = culprit.getStack(); if (stack.length > 0) { @@ -1024,36 +1019,6 @@ private void applyAnrProfile( // 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 @NotNull OptionsSource optionsSource) { - final SentryProfile profile = StackTraceConverter.convert(anrProfile); - final ProfileChunk chunk = - new ProfileChunk( - new SentryId(), - new SentryId(), - null, - new HashMap<>(0), - anrTimestampMs / 1000.0d, - ProfileChunk.PLATFORM_ANDROID, - options); - chunk.setSentryProfile(profile); - chunk.setDebugMeta(createAnrProfileDebugMeta(optionsSource)); - - final SentryId profilerId = Sentry.getCurrentScopes().captureProfileChunk(chunk); - if (SentryId.EMPTY_ID.equals(profilerId)) { - return null; - } else { - return chunk.getProfilerId(); } } @@ -1082,31 +1047,6 @@ private boolean hasOnlySystemFrames(@NotNull SentryEvent event) { } return true; } - - /** - * Creates debug metadata for an ANR profile chunk using the build metadata selected for the ANR - * event. - * - *

ANR profile chunks are captured after app relaunch. If the app was updated between the ANR - * and the relaunch, the current options may contain the new build's ProGuard UUID. The provided - * {@link OptionsSource} lets us resolve the profile chunk and ANR event to the same originating - * build. - */ - private @Nullable DebugMeta createAnrProfileDebugMeta( - final @NotNull OptionsSource optionsSource) { - final String proguardUuid = - getBuildOption( - PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource); - if (proguardUuid == null) { - // If no historical UUID is available, let the generic profile chunk pipeline apply the - // current options UUID as its normal best-effort fallback. - return null; - } - - final DebugMeta debugMeta = new DebugMeta(); - debugMeta.setImages(Collections.singletonList(createProguardDebugImage(proguardUuid))); - return debugMeta; - } } private static @NotNull DebugImage createProguardDebugImage(final @NotNull String proguardUuid) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java index 42a9e846b64..a28b8842542 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java @@ -91,11 +91,11 @@ private AnrFlameGraphAttachment() {} int parentId = NO_PARENT; for (int i = stack.length - 1; i >= 0; i--) { final @NotNull StackTraceElement element = stack[i]; - final @NotNull String frameSignature = StackTraceConverter.createFrameSignature(element); + final @NotNull String frameSignature = createFrameSignature(element); @Nullable Integer frameId = frameSignatureToIndex.get(frameSignature); if (frameId == null) { frameId = frames.size(); - frames.add(StackTraceConverter.createSentryStackFrame(element)); + frames.add(createSentryStackFrame(element)); frameSignatureToIndex.put(frameSignature, frameId); } @@ -133,6 +133,29 @@ private AnrFlameGraphAttachment() {} return flameGraph; } + private static @NotNull String createFrameSignature(final @NotNull StackTraceElement element) { + return element.getClassName() + + "#" + + element.getMethodName() + + "#" + + element.getFileName() + + "#" + + element.getLineNumber(); + } + + private static @NotNull SentryStackFrame createSentryStackFrame( + final @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; + } + private static @NotNull byte[] serialize(final @NotNull AnrFlameGraph flameGraph) throws IOException { final @NotNull ByteArrayOutputStream stream = new ByteArrayOutputStream(); 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 deleted file mode 100644 index 2514306f1a8..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java +++ /dev/null @@ -1,181 +0,0 @@ -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"; - - /** - * Timestamp offset used with synthetic ANR profile samples. (Currently 33 ms.) - * - *

Places the synthetic sample halfway to the next ANR polling tick. - */ - private static final double SYNTHETIC_SAMPLE_OFFSET_SECONDS = - (AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0d) / 1000.0d; - - /** - * 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); - } - - // Relay will reject ANR profiles with only one sample, even though they're still useful. - // (Relay's policy was defined with continuous profiles in mind, before ANR profiles were a - // thing.) If we only have one sample, synthesize another that only differs in its timestamp. - if (profile.getSamples().size() == 1) { - final @NotNull SentrySample originalSample = profile.getSamples().get(0); - final @NotNull SentrySample syntheticSample = createSyntheticSample(originalSample); - profile.getSamples().add(syntheticSample); - } - - 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 - 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 - 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; - } - - /** - * Creates a {@link SentrySample} identical to {@code originalSample}, save that its timestamp is - * advanced by {@link #SYNTHETIC_SAMPLE_OFFSET_SECONDS}. - * - *

Lets us produce a plausible synthetic sample without misleading the user about the ANR's - * actual duration or cause. - */ - @NotNull - private static SentrySample createSyntheticSample(@NotNull SentrySample originalSample) { - final @NotNull SentrySample syntheticSample = new SentrySample(originalSample); - syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS); - return syntheticSample; - } -} 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 12c1bf08429..648ac9450a9 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 @@ -6,13 +6,13 @@ import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.sentry.Attachment import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IScopes import io.sentry.IpAddressUtils +import io.sentry.JsonObjectReader import io.sentry.NoOpLogger -import io.sentry.ProfileChunk -import io.sentry.Sentry import io.sentry.SentryBaseEvent import io.sentry.SentryEvent import io.sentry.SentryLevel @@ -45,6 +45,7 @@ import io.sentry.cache.PersistingScopeObserver.USER_FILENAME import io.sentry.cache.tape.QueueFile import io.sentry.hints.AbnormalExit import io.sentry.hints.Backfillable +import io.sentry.protocol.AnrFlameGraph import io.sentry.protocol.Browser import io.sentry.protocol.Contexts import io.sentry.protocol.DebugImage @@ -65,6 +66,7 @@ import io.sentry.protocol.User import io.sentry.util.HintUtils import java.io.ByteArrayOutputStream import java.io.File +import java.io.StringReader import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -76,11 +78,7 @@ 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.argumentCaptor import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow @@ -1181,7 +1179,7 @@ class ApplicationExitInfoEventProcessorTest { } @Test - fun `does not set profile context when ANR profiling is disabled`() { + fun `does not attach a flame graph when ANR profiling is disabled`() { fixture.options.anrProfilingSampleRate = null val hint = anrHint(mechanism = "anr_foreground") val processed = @@ -1204,6 +1202,7 @@ class ApplicationExitInfoEventProcessorTest { ) } assertNull(processed.contexts.profile) + assertThat(hint.attachments).isEmpty() } @Test @@ -1220,50 +1219,33 @@ class ApplicationExitInfoEventProcessorTest { val hintTimestamp = System.currentTimeMillis() val hint = anrHint(mechanism = "anr_foreground", timestamp = hintTimestamp) - AnrProfileManager( - fixture.options, - AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + recordAnrProfile( + AnrStackTrace( + hintTimestamp, + arrayOf( + StackTraceElement( + "android.view.Choreographer", + "doFrame", + "Choreographer.java", + 1234, + ), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), ) - .apply { - add( - AnrStackTrace( - hintTimestamp, - 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) - val chunkCaptor = argumentCaptor() - verify(scopes).captureProfileChunk(chunkCaptor.capture()) - val sentryProfile = chunkCaptor.firstValue.sentryProfile + val processed = processor.process(SentryEvent(), hint) - assertNotNull(processed?.contexts?.profile) - assertNotNull(processed.contexts.profile?.profilerId) - assertNotNull(sentryProfile) - // Two samples are present b/c the converter adds a synthetic one to keep Relay happy. - assertEquals(2, sentryProfile.samples.size) - } + assertThat(processed?.contexts?.profile).isNull() + val attachment = hint.attachments.single() + assertThat(attachment.filename).isEqualTo("flamegraph.json") + assertThat(attachment.attachmentType).isEqualTo("event.flamegraph") + val nodes = flameGraph(attachment).nodes + assertThat(nodes.map { it.frameId to it.sampleCount }).containsExactly(0 to 1L, 1 to 1L) } @Test - fun `uses persisted proguard uuid for ANR profile chunk after app update`() { + fun `uses persisted proguard uuid for ANR flame graph after app update`() { fixture.options.anrProfilingSampleRate = 1.0 fixture.options.proguardUuid = "current-uuid" val processor = @@ -1278,39 +1260,79 @@ class ApplicationExitInfoEventProcessorTest { val hint = anrHint(mechanism = "anr_foreground", timestamp = 1_000) - AnrProfileManager( - fixture.options, - AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + recordAnrProfile( + AnrStackTrace( + 1_000, + arrayOf( + StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), ) - .apply { - add( - AnrStackTrace( - 1_000, - arrayOf( - StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), - StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), - ), - ) - ) - close() - } - AnrProfileRotationHelper.rotate() + ) - val scopes = mock() - whenever(scopes.captureProfileChunk(any())).thenReturn(SentryId()) + processor.process(SentryEvent(), hint) - mockStatic(Sentry::class.java).use { mockedSentry -> - mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + val images = flameGraph(hint.attachments.single()).debugMeta!!.images!! + assertEquals(1, images.size) + assertEquals(DebugImage.PROGUARD, images[0].type) + assertEquals("previous-uuid", images[0].uuid) + } - processor.process(SentryEvent(), hint) + @Test + fun `falls back to the current proguard uuid for ANR flame graph without a persisted one`() { + fixture.options.anrProfilingSampleRate = 1.0 + fixture.options.proguardUuid = "current-uuid" + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + setLastUpdateTime(2_000) - val chunkCaptor = argumentCaptor() - verify(scopes).captureProfileChunk(chunkCaptor.capture()) - val images = chunkCaptor.firstValue.debugMeta!!.images!! - assertEquals(1, images.size) - assertEquals(DebugImage.PROGUARD, images[0].type) - assertEquals("previous-uuid", images[0].uuid) - } + val hint = anrHint(mechanism = "anr_foreground", timestamp = 1_000) + recordAnrProfile( + AnrStackTrace( + 1_000, + arrayOf( + StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42), + StackTraceElement("android.os.Handler", "dispatchMessage", "Handler.java", 5678), + ), + ) + ) + + processor.process(SentryEvent(), hint) + + val images = flameGraph(hint.attachments.single()).debugMeta!!.images!! + assertThat(images.map { it.type to it.uuid }) + .containsExactly(DebugImage.PROGUARD to "current-uuid") + } + + @Test + fun `attaches ANR flame graph when no culprit is identified`() { + fixture.options.anrProfilingSampleRate = 1.0 + val processor = + fixture.getSut( + tmpDir, + populateScopeCache = false, + populateOptionsCache = false, + isSendDefaultPii = false, + ) + + val hintTimestamp = System.currentTimeMillis() + val hint = anrHint(mechanism = "anr_foreground", timestamp = hintTimestamp) + recordAnrProfile( + AnrStackTrace( + hintTimestamp, + arrayOf(StackTraceElement("com.example.MyApp", "blocked", "MyApp.java", 42)), + ) + ) + + val processed = processor.process(SentryEvent(), hint)!! + + assertThat(processed.exceptions!!.single().mechanism!!.type).isEqualTo("AppExitInfo") + assertThat(flameGraph(hint.attachments.single()).nodes).hasSize(1) } @Test @@ -1456,6 +1478,25 @@ class ApplicationExitInfoEventProcessorTest { return processor.process(original, hint)!! } + private fun recordAnrProfile(vararg stacks: AnrStackTrace) { + AnrProfileManager( + fixture.options, + AnrProfileRotationHelper.getFileForRecording(File(fixture.options.cacheDirPath!!)), + ) + .apply { + stacks.forEach { add(it) } + close() + } + AnrProfileRotationHelper.rotate() + } + + private fun flameGraph(attachment: Attachment): AnrFlameGraph = + AnrFlameGraph.Deserializer() + .deserialize( + JsonObjectReader(StringReader(String(attachment.bytes!!, Charsets.UTF_8))), + NoOpLogger.getInstance(), + ) + private fun setLastUpdateTime(lastUpdateTime: Long) { ContextUtils.getPackageInfo(fixture.context, fixture.buildInfo)!!.lastUpdateTime = lastUpdateTime diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt index 4ad8f5d9ec6..c7b294ff635 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt @@ -56,6 +56,20 @@ class AnrFlameGraphAttachmentTest { ) } + @Test + fun `marks native frames and omits unknown line numbers`() { + val nativeFrame = StackTraceElement("java.lang.Object", "wait", null, -2) + val profile = AnrProfile(listOf(AnrStackTrace(1000, arrayOf(nativeFrame)))) + + val attachment = AnrFlameGraphAttachment.fromAnrProfile(profile, null)!! + + val flamegraph = parse(String(attachment.bytes!!, Charsets.UTF_8)) as Map<*, *> + assertThat(flamegraph["frames"]) + .isEqualTo( + listOf(mapOf("function" to "wait", "module" to "java.lang.Object", "native" to true)) + ) + } + @Test fun `creates a flame graph attachment`() { val profile = AnrProfile(listOf(AnrStackTrace(1000, arrayOf(main)))) 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 deleted file mode 100644 index 9dcde8eb4a6..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrStackTraceConverterTest.kt +++ /dev/null @@ -1,249 +0,0 @@ -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) - // Two samples are present b/c the converter adds a synthetic one to keep Relay happy. - Assert.assertEquals(2, 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 testAddSyntheticSampleIfOnlyOneSamplePresent() { - 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 originalSample = profile.samples[0] - val syntheticSample = profile.samples[1] - val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0 - - Assert.assertEquals( - originalSample.timestamp + expectedOffsetSeconds, - syntheticSample.timestamp, - 0.001, - ) - - Assert.assertTrue(profile.stacks[syntheticSample.stackId].isNotEmpty()) - Assert.assertEquals(2, profile.samples.size) - Assert.assertEquals(1, profile.stacks.size) - Assert.assertEquals(1, profile.frames.size) - } - - @Test - fun testDoNotAddSyntheticSampleIfMultipleSamplesPresent() { - val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42)) - - val anrStackTraces: MutableList = ArrayList() - anrStackTraces.add(AnrStackTrace(1000, elements)) - anrStackTraces.add(AnrStackTrace(2000, elements)) - - val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces)) - - Assert.assertEquals(2, profile.samples.size) - Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001) - Assert.assertEquals(2.0, profile.samples[1].timestamp, 0.001) - Assert.assertEquals(1, profile.stacks.size) - Assert.assertEquals(1, profile.frames.size) - } - - @Test - fun testDoNotAddSyntheticSampleIfNoSamplesPresent() { - val profile = StackTraceConverter.convert(AnrProfile(ArrayList())) - - Assert.assertEquals(0, profile.samples.size) - Assert.assertEquals(0, profile.stacks.size) - Assert.assertEquals(0, profile.frames.size) - } - - @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()) - } -} From ca663ea69fe95f6bb989ee4fe704127a51bb2e40 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 12 Nov 2026 13:33:05 +0100 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ff06f1e99..1ee639da471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ > - Java 8 support will be dropped. SDK 9.0.0 requires Java 11+. See [#6469](https://github.com/getsentry/sentry-java/issues/6469). > - The Android `minSdk` will be raised from 21 to 24. See [#5508](https://github.com/getsentry/sentry-java/issues/5508). +## Unreleased + +### Behavioral Changes + +- ANR profiles are now sent as a flame graph attachment on the ANR event instead of a separate profile chunk ([#6597](https://github.com/getsentry/sentry-java/pull/6597)) + ## 8.64.0 ### Breaking Changes