From 46f32f21aba80d5a4802b9eb5ddf5f860d63b71c Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 13 May 2026 10:25:12 +0200 Subject: [PATCH 1/2] feat(android): Parse memory and GC info from ANR thread dumps Co-Authored-By: Claude Opus 4.6 --- .../api/sentry-android-core.api | 2 + .../sentry/android/core/AnrV2Integration.java | 31 ++++- .../ApplicationExitInfoEventProcessor.java | 31 +++++ .../threaddump/ThreadDumpMemoryInfo.java | 114 ++++++++++++++++ .../ThreadDumpMemoryInfoParser.java | 127 ++++++++++++++++++ .../internal/threaddump/ThreadDumpParser.java | 10 ++ .../threaddump/ThreadDumpMemoryInfoTest.kt | 102 ++++++++++++++ .../threaddump/ThreadDumpParserTest.kt | 31 +++++ 8 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfo.java create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoParser.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 3d4512fc2b..a7eeeac554 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -161,6 +161,8 @@ public class io/sentry/android/core/AnrV2Integration : io/sentry/Integration, ja public final class io/sentry/android/core/AnrV2Integration$AnrV2Hint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/AbnormalExit, io/sentry/hints/Backfillable { public fun (JLio/sentry/ILogger;JZZ)V + public fun (JLio/sentry/ILogger;JZZLio/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfo;)V + public fun getThreadDumpMemoryInfo ()Lio/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfo; public fun ignoreCurrentThread ()Z public fun isFlushable (Lio/sentry/protocol/SentryId;)Z public fun mechanism ()Ljava/lang/String; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index af3a942c8c..98744f85c4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -17,6 +17,7 @@ import io.sentry.SentryOptions; import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.threaddump.Lines; +import io.sentry.android.core.internal.threaddump.ThreadDumpMemoryInfo; import io.sentry.android.core.internal.threaddump.ThreadDumpParser; import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; @@ -154,7 +155,8 @@ public boolean shouldReportHistorical() { options.getLogger(), anrTimestamp, shouldEnrich, - isBackground); + isBackground, + result.memoryInfo); final Hint hint = HintUtils.createWithTypeCheckHint(anrHint); @@ -209,6 +211,7 @@ public boolean shouldReportHistorical() { final @NotNull List threads = threadDumpParser.getThreads(); final @NotNull List debugImages = threadDumpParser.getDebugImages(); + final @Nullable ThreadDumpMemoryInfo memoryInfo = threadDumpParser.getMemoryInfo(); if (threads.isEmpty()) { // if the list is empty this means the system failed to capture a proper thread dump of @@ -217,7 +220,7 @@ public boolean shouldReportHistorical() { // fall back to not reporting them return new ParseResult(ParseResult.Type.NO_DUMP); } - return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages); + return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages, memoryInfo); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e); return new ParseResult(ParseResult.Type.ERROR, dump); @@ -249,16 +252,33 @@ public static final class AnrV2Hint extends BlockingFlushHint private final boolean isBackgroundAnr; + private final @Nullable ThreadDumpMemoryInfo threadDumpMemoryInfo; + public AnrV2Hint( final long flushTimeoutMillis, final @NotNull ILogger logger, final long timestamp, final boolean shouldEnrich, final boolean isBackgroundAnr) { + this(flushTimeoutMillis, logger, timestamp, shouldEnrich, isBackgroundAnr, null); + } + + public AnrV2Hint( + final long flushTimeoutMillis, + final @NotNull ILogger logger, + final long timestamp, + final boolean shouldEnrich, + final boolean isBackgroundAnr, + final @Nullable ThreadDumpMemoryInfo threadDumpMemoryInfo) { super(flushTimeoutMillis, logger); this.timestamp = timestamp; this.shouldEnrich = shouldEnrich; this.isBackgroundAnr = isBackgroundAnr; + this.threadDumpMemoryInfo = threadDumpMemoryInfo; + } + + public @Nullable ThreadDumpMemoryInfo getThreadDumpMemoryInfo() { + return threadDumpMemoryInfo; } @Override @@ -303,12 +323,14 @@ enum Type { final byte[] dump; final @Nullable List threads; final @Nullable List debugImages; + final @Nullable ThreadDumpMemoryInfo memoryInfo; ParseResult(final @NotNull Type type) { this.type = type; this.dump = null; this.threads = null; this.debugImages = null; + this.memoryInfo = null; } ParseResult(final @NotNull Type type, final byte[] dump) { @@ -316,17 +338,20 @@ enum Type { this.dump = dump; this.threads = null; this.debugImages = null; + this.memoryInfo = null; } ParseResult( final @NotNull Type type, final byte[] dump, final @Nullable List threads, - final @Nullable List debugImages) { + final @Nullable List debugImages, + final @Nullable ThreadDumpMemoryInfo memoryInfo) { this.type = type; this.dump = dump; this.threads = threads; this.debugImages = debugImages; + this.memoryInfo = memoryInfo; } } } 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 2eca0e68b5..bcc8c2324e 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 @@ -45,6 +45,7 @@ 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.threaddump.ThreadDumpMemoryInfo; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; @@ -723,6 +724,36 @@ public void applyPostEnrichment( // Set app foreground state setAppForeground(event, !isBackgroundAnr); + + enrichDeviceFromThreadDump(event, rawHint); + } + + private void enrichDeviceFromThreadDump( + final @NotNull SentryEvent event, final @NotNull Object rawHint) { + if (!(rawHint instanceof AnrV2Integration.AnrV2Hint)) { + return; + } + final @Nullable ThreadDumpMemoryInfo memoryInfo = + ((AnrV2Integration.AnrV2Hint) rawHint).getThreadDumpMemoryInfo(); + if (memoryInfo == null) { + return; + } + + Device device = event.getContexts().getDevice(); + if (device == null) { + device = new Device(); + event.getContexts().setDevice(device); + } + + if (memoryInfo.getFreeMemoryUntilOOMEBytes() != null) { + device.setFreeMemory(memoryInfo.getFreeMemoryUntilOOMEBytes()); + } + if (memoryInfo.getFreeMemoryBytes() != null) { + device.setUsableMemory(memoryInfo.getFreeMemoryBytes()); + } + if (memoryInfo.getMaxMemoryBytes() != null) { + device.setMemorySize(memoryInfo.getMaxMemoryBytes()); + } } private void setDefaultAnrFingerprint( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfo.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfo.java new file mode 100644 index 0000000000..4e600e94ac --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfo.java @@ -0,0 +1,114 @@ +package io.sentry.android.core.internal.threaddump; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * Holds memory and GC metrics parsed from an ANRv2 thread dump. + * + *

Memory sizes are in bytes. GC times are in milliseconds. + */ +@ApiStatus.Internal +public final class ThreadDumpMemoryInfo { + + private @Nullable Long freeMemoryBytes; + private @Nullable Long freeMemoryUntilGcBytes; + private @Nullable Long freeMemoryUntilOOMEBytes; + private @Nullable Long totalMemoryBytes; + private @Nullable Long maxMemoryBytes; + + private @Nullable Long totalGcCount; + private @Nullable Double totalGcTimeMs; + private @Nullable Long totalBlockingGcCount; + private @Nullable Double totalBlockingGcTimeMs; + private @Nullable Long totalPreOomeGcCount; + private @Nullable Double totalTimeWaitingForGcMs; + + public @Nullable Long getFreeMemoryBytes() { + return freeMemoryBytes; + } + + public void setFreeMemoryBytes(final @Nullable Long freeMemoryBytes) { + this.freeMemoryBytes = freeMemoryBytes; + } + + public @Nullable Long getFreeMemoryUntilGcBytes() { + return freeMemoryUntilGcBytes; + } + + public void setFreeMemoryUntilGcBytes(final @Nullable Long freeMemoryUntilGcBytes) { + this.freeMemoryUntilGcBytes = freeMemoryUntilGcBytes; + } + + public @Nullable Long getFreeMemoryUntilOOMEBytes() { + return freeMemoryUntilOOMEBytes; + } + + public void setFreeMemoryUntilOOMEBytes(final @Nullable Long freeMemoryUntilOOMEBytes) { + this.freeMemoryUntilOOMEBytes = freeMemoryUntilOOMEBytes; + } + + public @Nullable Long getTotalMemoryBytes() { + return totalMemoryBytes; + } + + public void setTotalMemoryBytes(final @Nullable Long totalMemoryBytes) { + this.totalMemoryBytes = totalMemoryBytes; + } + + public @Nullable Long getMaxMemoryBytes() { + return maxMemoryBytes; + } + + public void setMaxMemoryBytes(final @Nullable Long maxMemoryBytes) { + this.maxMemoryBytes = maxMemoryBytes; + } + + public @Nullable Long getTotalGcCount() { + return totalGcCount; + } + + public void setTotalGcCount(final @Nullable Long totalGcCount) { + this.totalGcCount = totalGcCount; + } + + public @Nullable Double getTotalGcTimeMs() { + return totalGcTimeMs; + } + + public void setTotalGcTimeMs(final @Nullable Double totalGcTimeMs) { + this.totalGcTimeMs = totalGcTimeMs; + } + + public @Nullable Long getTotalBlockingGcCount() { + return totalBlockingGcCount; + } + + public void setTotalBlockingGcCount(final @Nullable Long totalBlockingGcCount) { + this.totalBlockingGcCount = totalBlockingGcCount; + } + + public @Nullable Double getTotalBlockingGcTimeMs() { + return totalBlockingGcTimeMs; + } + + public void setTotalBlockingGcTimeMs(final @Nullable Double totalBlockingGcTimeMs) { + this.totalBlockingGcTimeMs = totalBlockingGcTimeMs; + } + + public @Nullable Long getTotalPreOomeGcCount() { + return totalPreOomeGcCount; + } + + public void setTotalPreOomeGcCount(final @Nullable Long totalPreOomeGcCount) { + this.totalPreOomeGcCount = totalPreOomeGcCount; + } + + public @Nullable Double getTotalTimeWaitingForGcMs() { + return totalTimeWaitingForGcMs; + } + + public void setTotalTimeWaitingForGcMs(final @Nullable Double totalTimeWaitingForGcMs) { + this.totalTimeWaitingForGcMs = totalTimeWaitingForGcMs; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoParser.java new file mode 100644 index 0000000000..8a01d73dc3 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoParser.java @@ -0,0 +1,127 @@ +package io.sentry.android.core.internal.threaddump; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class ThreadDumpMemoryInfoParser { + + private static final long KB = 1024; + private static final long MB = 1024 * KB; + private static final long GB = 1024 * MB; + + private static final String FREE_MEMORY_PREFIX = "Free memory "; + private static final String FREE_MEMORY_UNTIL_GC_PREFIX = "Free memory until GC "; + private static final String FREE_MEMORY_UNTIL_OOME_PREFIX = "Free memory until OOME "; + private static final String TOTAL_MEMORY_PREFIX = "Total memory "; + private static final String MAX_MEMORY_PREFIX = "Max memory "; + private static final String TOTAL_TIME_WAITING_FOR_GC_PREFIX = + "Total time waiting for GC to complete: "; + private static final String TOTAL_GC_COUNT_PREFIX = "Total GC count: "; + private static final String TOTAL_GC_TIME_PREFIX = "Total GC time: "; + private static final String TOTAL_BLOCKING_GC_COUNT_PREFIX = "Total blocking GC count: "; + private static final String TOTAL_BLOCKING_GC_TIME_PREFIX = "Total blocking GC time: "; + private static final String TOTAL_PRE_OOME_GC_COUNT_PREFIX = "Total pre-OOME GC count: "; + + private @Nullable ThreadDumpMemoryInfo memoryInfo; + + @Nullable + ThreadDumpMemoryInfo getMemoryInfo() { + return memoryInfo; + } + + void parseLine(final @NotNull String text) { + if (text.startsWith(FREE_MEMORY_UNTIL_OOME_PREFIX)) { + getOrCreateMemoryInfo() + .setFreeMemoryUntilOOMEBytes( + parsePrettySize(text.substring(FREE_MEMORY_UNTIL_OOME_PREFIX.length()))); + } else if (text.startsWith(FREE_MEMORY_UNTIL_GC_PREFIX)) { + getOrCreateMemoryInfo() + .setFreeMemoryUntilGcBytes( + parsePrettySize(text.substring(FREE_MEMORY_UNTIL_GC_PREFIX.length()))); + } else if (text.startsWith(FREE_MEMORY_PREFIX)) { + getOrCreateMemoryInfo() + .setFreeMemoryBytes(parsePrettySize(text.substring(FREE_MEMORY_PREFIX.length()))); + } else if (text.startsWith(TOTAL_MEMORY_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalMemoryBytes(parsePrettySize(text.substring(TOTAL_MEMORY_PREFIX.length()))); + } else if (text.startsWith(MAX_MEMORY_PREFIX)) { + getOrCreateMemoryInfo() + .setMaxMemoryBytes(parsePrettySize(text.substring(MAX_MEMORY_PREFIX.length()))); + } else if (text.startsWith(TOTAL_TIME_WAITING_FOR_GC_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalTimeWaitingForGcMs( + parseTimeMs(text.substring(TOTAL_TIME_WAITING_FOR_GC_PREFIX.length()))); + } else if (text.startsWith(TOTAL_GC_TIME_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalGcTimeMs(parseTimeMs(text.substring(TOTAL_GC_TIME_PREFIX.length()))); + } else if (text.startsWith(TOTAL_GC_COUNT_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalGcCount(parseLongOrNull(text.substring(TOTAL_GC_COUNT_PREFIX.length()))); + } else if (text.startsWith(TOTAL_BLOCKING_GC_TIME_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalBlockingGcTimeMs( + parseTimeMs(text.substring(TOTAL_BLOCKING_GC_TIME_PREFIX.length()))); + } else if (text.startsWith(TOTAL_BLOCKING_GC_COUNT_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalBlockingGcCount( + parseLongOrNull(text.substring(TOTAL_BLOCKING_GC_COUNT_PREFIX.length()))); + } else if (text.startsWith(TOTAL_PRE_OOME_GC_COUNT_PREFIX)) { + getOrCreateMemoryInfo() + .setTotalPreOomeGcCount( + parseLongOrNull(text.substring(TOTAL_PRE_OOME_GC_COUNT_PREFIX.length()))); + } + } + + private @NotNull ThreadDumpMemoryInfo getOrCreateMemoryInfo() { + if (memoryInfo == null) { + memoryInfo = new ThreadDumpMemoryInfo(); + } + return memoryInfo; + } + + /** + * Matches Android's PrettySize output: number followed by unit with no space, e.g. "3107KB". + * + *

Counterpart to + * https://cs.android.com/android/platform/superproject/+/android-latest-release:art/libartbase/base/utils.cc;l=232-251;drc=d0d3deb269b1e14de2ec2707815e38bc95de570c + */ + private @Nullable Long parsePrettySize(final @NotNull String sizeString) { + final String trimmed = sizeString.trim(); + try { + if (trimmed.endsWith("GB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * GB; + } else if (trimmed.endsWith("MB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * MB; + } else if (trimmed.endsWith("KB")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * KB; + } else if (trimmed.endsWith("B")) { + return Long.parseLong(trimmed.substring(0, trimmed.length() - 1)); + } + } catch (NumberFormatException e) { + return null; + } + return null; + } + + private static @Nullable Double parseTimeMs(final @NotNull String timeString) { + final String trimmed = timeString.trim(); + if (trimmed.endsWith("ms")) { + try { + // Double.parseDouble is locale-independent (always uses '.' as decimal separator), + // which matches the ART runtime output format. + return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private static @Nullable Long parseLongOrNull(final @NotNull String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java index 5f70e39f8b..feb6f5e896 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java @@ -109,6 +109,9 @@ public class ThreadDumpParser { private final @NotNull List threads; + private final @NotNull ThreadDumpMemoryInfoParser memoryInfoParser = + new ThreadDumpMemoryInfoParser(); + public ThreadDumpParser(final @NotNull SentryOptions options, final boolean isBackground) { this.options = options; this.isBackground = isBackground; @@ -127,6 +130,11 @@ public List getThreads() { return threads; } + @Nullable + public ThreadDumpMemoryInfo getMemoryInfo() { + return memoryInfoParser.getMemoryInfo(); + } + public void parse(final @NotNull Lines lines) { final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); @@ -148,6 +156,8 @@ public void parse(final @NotNull Lines lines) { if (thread != null) { threads.add(thread); } + } else { + memoryInfoParser.parseLine(text); } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoTest.kt new file mode 100644 index 0000000000..87670d96bc --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpMemoryInfoTest.kt @@ -0,0 +1,102 @@ +package io.sentry.android.core.internal.threaddump + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ThreadDumpMemoryInfoTest { + + @Test + fun `parses pretty size bytes`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Free memory 0B") + assertEquals(0L, parser.memoryInfo!!.freeMemoryBytes) + + val parser2 = ThreadDumpMemoryInfoParser() + parser2.parseLine("Free memory 512B") + assertEquals(512L, parser2.memoryInfo!!.freeMemoryBytes) + } + + @Test + fun `parses pretty size kilobytes`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Free memory 3107KB") + assertEquals(3107L * 1024, parser.memoryInfo!!.freeMemoryBytes) + } + + @Test + fun `parses pretty size megabytes`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Free memory until OOME 187MB") + assertEquals(187L * 1024 * 1024, parser.memoryInfo!!.freeMemoryUntilOOMEBytes) + } + + @Test + fun `parses pretty size gigabytes`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Max memory 2GB") + assertEquals(2L * 1024 * 1024 * 1024, parser.memoryInfo!!.maxMemoryBytes) + } + + @Test + fun `sets null for invalid pretty size`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Free memory 100TB") + assertNull(parser.memoryInfo!!.freeMemoryBytes) + } + + @Test + fun `parses time in milliseconds`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Total GC time: 11.807ms") + assertEquals(11.807, parser.memoryInfo!!.totalGcTimeMs) + } + + @Test + fun `parses all memory fields`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Free memory 3107KB") + parser.parseLine("Free memory until GC 3107KB") + parser.parseLine("Free memory until OOME 187MB") + parser.parseLine("Total memory 7592KB") + parser.parseLine("Max memory 192MB") + + val info = parser.memoryInfo + assertNotNull(info) + assertEquals(3107L * 1024, info.freeMemoryBytes) + assertEquals(3107L * 1024, info.freeMemoryUntilGcBytes) + assertEquals(187L * 1024 * 1024, info.freeMemoryUntilOOMEBytes) + assertEquals(7592L * 1024, info.totalMemoryBytes) + assertEquals(192L * 1024 * 1024, info.maxMemoryBytes) + } + + @Test + fun `parses all gc fields`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("Total time waiting for GC to complete: 8.054ms") + parser.parseLine("Total GC count: 1") + parser.parseLine("Total GC time: 11.807ms") + parser.parseLine("Total blocking GC count: 1") + parser.parseLine("Total blocking GC time: 11.873ms") + parser.parseLine("Total pre-OOME GC count: 0") + + val info = parser.memoryInfo + assertNotNull(info) + assertEquals(8.054, info.totalTimeWaitingForGcMs) + assertEquals(1L, info.totalGcCount) + assertEquals(11.807, info.totalGcTimeMs) + assertEquals(1L, info.totalBlockingGcCount) + assertEquals(11.873, info.totalBlockingGcTimeMs) + assertEquals(0L, info.totalPreOomeGcCount) + } + + @Test + fun `ignores unrelated lines`() { + val parser = ThreadDumpMemoryInfoParser() + parser.parseLine("some random line") + parser.parseLine("DALVIK THREADS (29):") + parser.parseLine("") + assertNull(parser.memoryInfo) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index 604e2e8418..2bd587a75e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -160,6 +160,28 @@ class ThreadDumpParserTest { assertEquals("ba489d4985c0cf173209da67405662f9", image.codeId) } + @Test + fun `parses memory info from thread dump`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + + val memoryInfo = parser.memoryInfo + assertNotNull(memoryInfo) + assertEquals(3107L * 1024, memoryInfo.freeMemoryBytes) + assertEquals(3107L * 1024, memoryInfo.freeMemoryUntilGcBytes) + assertEquals(187L * 1024 * 1024, memoryInfo.freeMemoryUntilOOMEBytes) + assertEquals(7592L * 1024, memoryInfo.totalMemoryBytes) + assertEquals(192L * 1024 * 1024, memoryInfo.maxMemoryBytes) + assertEquals(1L, memoryInfo.totalGcCount) + assertEquals(11.807, memoryInfo.totalGcTimeMs) + assertEquals(1L, memoryInfo.totalBlockingGcCount) + assertEquals(11.873, memoryInfo.totalBlockingGcTimeMs) + assertEquals(0L, memoryInfo.totalPreOomeGcCount) + assertEquals(8.054, memoryInfo.totalTimeWaitingForGcMs) + } + @Test fun `thread dump garbage`() { val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) @@ -168,4 +190,13 @@ class ThreadDumpParserTest { parser.parse(lines) assertTrue(parser.threads.isEmpty()) } + + @Test + fun `garbage thread dump has no memory info`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) + val parser = + ThreadDumpParser(SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false) + parser.parse(lines) + assertNull(parser.memoryInfo) + } } From 9cb9fdff4239530f9a69b742f6658838201f19a9 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 13 May 2026 11:51:45 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda85d8b9..18fd672f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Parse memory and GC info from ANR thread dumps and enrich Device context ([#5428](https://github.com/getsentry/sentry-java/pull/5428)) - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) ## 8.41.0