Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b286ad5
Profile main thread when ANR and report ANR profiles to sentry
markushi Nov 12, 2025
a62b5e8
docs(changelog): Add ANR profiling integration entry
markushi Nov 12, 2025
ae66f73
Merge branch 'main' into markushi/feat/anr-profiling
markushi Nov 12, 2025
f226d84
Fix api dump file
markushi Nov 13, 2025
7d423a4
Address PR feedback
markushi Nov 13, 2025
5824f8f
Merge branch 'markushi/feat/anr-profiling' of github.com:getsentry/se…
markushi Nov 13, 2025
6e7fff2
Merge branch 'main' into markushi/feat/anr-profiling
markushi Dec 1, 2025
3d4d952
refactor(anr): Implement lazy file rotation for ANR profiling
markushi Dec 2, 2025
69170ce
Merge branch 'main' into markushi/feat/anr-profiling
markushi Dec 3, 2025
cee86fc
Update Changelog
markushi Dec 3, 2025
49c2e20
Address PR feedback
markushi Dec 4, 2025
93141dd
Improve folding logic, cleanup tests
markushi Dec 16, 2025
a59bf08
Add more tests and address feedback
markushi Dec 16, 2025
5bfff6a
Merge branch 'main' into markushi/feat/anr-profiling
markushi Dec 16, 2025
6c9acd7
Update CHANGELOG.md
markushi Dec 17, 2025
4c95292
Merge branch 'main' into markushi/feat/anr-profiling
markushi Dec 18, 2025
cdd74b9
Merge branch 'main' into markushi/feat/anr-profiling
markushi Jan 29, 2026
e67c713
Address PR feedcback
markushi Jan 30, 2026
0cd877d
Merge branch 'main' into markushi/feat/anr-profiling
markushi Jan 30, 2026
ff3c01e
Merge branch 'main' into markushi/feat/anr-profiling
markushi Feb 11, 2026
47db30e
Merge branch 'markushi/feat/anr-profiling' of github.com:getsentry/se…
markushi Feb 11, 2026
aefa921
Move logic to event processor
markushi Feb 11, 2026
e68c4cf
Update changelog
markushi Feb 11, 2026
e92a82b
Ensure integration is tracked
markushi Feb 11, 2026
6ecd31e
Address PR feedback
markushi Feb 11, 2026
29b2ff0
Fix tests
markushi Feb 11, 2026
a15602b
Match manifest property to convention, enable profiling in sample app
markushi Feb 11, 2026
d33ccfc
Merge branch 'main' into markushi/feat/anr-profiling
markushi Feb 12, 2026
28c7bfa
Add more bound checks and null guards
markushi Feb 12, 2026
aa79a11
Remove outdated meta-data
markushi Feb 12, 2026
175fc39
Properly handle foreground transitions
markushi Feb 12, 2026
aeb7d72
Address PR comments
markushi Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<meta-data android:name="io.sentry.anr.profiling.enable" android:value="true" />`

### Dependencies

Expand Down Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -546,6 +548,84 @@ 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 <init> ([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 <init> ()V
public static fun identify (Ljava/util/List;)Lio/sentry/android/core/anr/AggregatedStackTrace;
public static fun isSystemFrame (Ljava/lang/String;)Z
}

public class io/sentry/android/core/anr/AnrException : java/lang/Exception {
public fun <init> ()V
public fun <init> (Ljava/lang/String;)V
}

public class io/sentry/android/core/anr/AnrProfile {
public final field endTimeMs J
public final field stacks Ljava/util/List;
public final field startTimeMs J
public fun <init> (Ljava/util/List;)V
}

public class io/sentry/android/core/anr/AnrProfileManager : java/io/Closeable {
public fun <init> (Lio/sentry/SentryOptions;)V
public fun <init> (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 <init> ()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 <init> ()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 <init> (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 <init> ()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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,6 +142,8 @@ static void loadDefaultAndMetadataOptions(
.getRuntimeManager()
.runWithRelaxedPolicy(() -> getCacheDir(finalContext).getAbsolutePath()));

AnrProfileRotationHelper.rotate();

readDefaultOptionValues(options, finalContext, buildInfoProvider);
AppState.getInstance().registerLifecycleObserver(options);
}
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,23 @@
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;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.SentryStackTraceFactory;
import io.sentry.SpanContext;
import io.sentry.android.core.anr.AggregatedStackTrace;
import io.sentry.android.core.anr.AnrCulpritIdentifier;
import io.sentry.android.core.anr.AnrException;
import io.sentry.android.core.anr.AnrProfile;
import io.sentry.android.core.anr.AnrProfileManager;
import io.sentry.android.core.anr.AnrProfileRotationHelper;
import io.sentry.android.core.anr.StackTraceConverter;
import io.sentry.android.core.internal.util.CpuInfoUtils;
import io.sentry.cache.PersistingOptionsObserver;
import io.sentry.cache.PersistingScopeObserver;
Expand All @@ -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;
Expand Down Expand Up @@ -700,16 +714,33 @@ 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(
final @NotNull SentryEvent event, final boolean isBackgroundAnr) {
// 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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering whether we should guard this behind isEnableAnrProfiling or actually just do it for all ANRs going forward? I think AEI also gives quite a lot of noise with just system frames, right?

Copy link
Member Author

@markushi markushi Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly - we should do this everywhere in the long run. I still would guard this right now - otherwise we'll have a breaking change in default behavior. I can create a follow up ticket for the next major.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we've done this quite a lot in the past (breaking behaviour change that affects grouping) so I'm not opposed to doing this now and not wait for the next major 😅 If it improves things I think I'd rather do it sooner. But your call here (we could also wait to get some adoption and see how it performs before doing this for everyone)

// 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"));
}
Expand Down Expand Up @@ -777,5 +808,142 @@ 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 SentryId profilerId = captureAnrProfile(anrTimestamp, anrProfile);

final StackTraceElement[] stack = culprit.getStack();
if (stack.length > 0) {
final StackTraceElement stackTraceElement = stack[0];
final String message =
stackTraceElement.getClassName() + "." + stackTraceElement.getMethodName();
final AnrException exception = new AnrException(message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the new AnrException type intentional here? Because it's a new type it will create new groups (even if the profile-derived stacktrace matches the one from AEI). Not sure if we should keep it as ApplicationNotResponding?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think I looked into this early on, but ApplicationNotResponding requires a non-null Thread object, which seemed to be widely used and hard to refactor. But that's a good point, let me check that again.

exception.setStackTrace(stack);

final @NotNull List<SentryException> sentryException =
sentryExceptionFactory.getSentryExceptions(exception);
for (final @NotNull SentryException e : sentryException) {
final Mechanism mechanism = new Mechanism();
mechanism.setType("ANR");
e.setMechanism(mechanism);
}
// Replaces the original ANR exception with the profile-derived one,
// as we assume the profiling stacktrace is more detailed
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just curious, but is there a way to send both the ANR event and the profile chunk in the same envelope?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude says yes, this should be valid for the backend😅

Yes, this works. The client can send both in the same envelope.
During split_envelope() at relay-server/src/services/processor.rs:357-365, Relay splits them apart before any event-type routing:

  1. ProfileChunk items are extracted into their own ProcessingGroup::ProfileChunk envelope
  2. The remaining error event goes into ProcessingGroup::Error
  3. Both are processed independently through their respective pipelines

We'd need to attach the profile directly to the event (or via hint) and then do something similar as we do for e.g. attachments (

if (attachments != null) {
for (final Attachment attachment : attachments) {
final SentryEnvelopeItem attachmentItem =
SentryEnvelopeItem.fromAttachment(
options.getSerializer(),
options.getLogger(),
attachment,
options.getMaxAttachmentSize());
envelopeItems.add(attachmentItem);
}
}
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, or replay_recording, too.

I guess this will be better in terms of not sending one without the other, but I don't have a strong preference right now, we can probably create a followup issue if you don't feel like doing it now.

if (SentryId.EMPTY_ID.equals(profilerId)) {
return null;
} else {
return chunk.getProfilerId();
}
}

private boolean hasOnlySystemFrames(@NotNull SentryEvent event) {
final List<SentryException> 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<SentryStackFrame> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading