Skip to content

Commit 9669c2d

Browse files
authored
feat(android): Parse memory and GC info from ANR thread dumps (#5428)
1 parent 44472da commit 9669c2d

15 files changed

Lines changed: 888 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Enable via `options.isAttachRawTombstone = true` or manifest: `<meta-data android:name="io.sentry.tombstone.attach-raw" android:value="true" />`
99
- Add API to clear feature flags from scopes ([#5426](https://github.com/getsentry/sentry-java/pull/5426))
1010
- 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))
11+
- Parse ART memory and garbage collector info from ANR tombstones into ART context ([#5428](https://github.com/getsentry/sentry-java/pull/5428))
1112

1213
### Dependencies
1314

sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import io.sentry.hints.AbnormalExit;
2323
import io.sentry.hints.Backfillable;
2424
import io.sentry.hints.BlockingFlushHint;
25+
import io.sentry.protocol.ArtContext;
2526
import io.sentry.protocol.DebugImage;
2627
import io.sentry.protocol.DebugMeta;
2728
import io.sentry.protocol.Message;
@@ -173,6 +174,9 @@ public boolean shouldReportHistorical() {
173174
debugMeta.setImages(result.debugImages);
174175
event.setDebugMeta(debugMeta);
175176
}
177+
if (result.artContext != null) {
178+
event.getContexts().setArt(result.artContext);
179+
}
176180
}
177181
event.setLevel(SentryLevel.FATAL);
178182
event.setTimestamp(DateUtils.getDateTime(anrTimestamp));
@@ -209,6 +213,7 @@ public boolean shouldReportHistorical() {
209213

210214
final @NotNull List<SentryThread> threads = threadDumpParser.getThreads();
211215
final @NotNull List<DebugImage> debugImages = threadDumpParser.getDebugImages();
216+
final @Nullable ArtContext artContext = threadDumpParser.getArtContext();
212217

213218
if (threads.isEmpty()) {
214219
// if the list is empty this means the system failed to capture a proper thread dump of
@@ -217,7 +222,7 @@ public boolean shouldReportHistorical() {
217222
// fall back to not reporting them
218223
return new ParseResult(ParseResult.Type.NO_DUMP);
219224
}
220-
return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages);
225+
return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages, artContext);
221226
} catch (Throwable e) {
222227
options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e);
223228
return new ParseResult(ParseResult.Type.ERROR, dump);
@@ -286,33 +291,38 @@ enum Type {
286291
}
287292

288293
final Type type;
289-
final byte[] dump;
294+
final @Nullable byte[] dump;
290295
final @Nullable List<SentryThread> threads;
291296
final @Nullable List<DebugImage> debugImages;
297+
final @Nullable ArtContext artContext;
292298

293299
ParseResult(final @NotNull Type type) {
294300
this.type = type;
295301
this.dump = null;
296302
this.threads = null;
297303
this.debugImages = null;
304+
this.artContext = null;
298305
}
299306

300307
ParseResult(final @NotNull Type type, final byte[] dump) {
301308
this.type = type;
302309
this.dump = dump;
303310
this.threads = null;
304311
this.debugImages = null;
312+
this.artContext = null;
305313
}
306314

307315
ParseResult(
308316
final @NotNull Type type,
309317
final byte[] dump,
310318
final @Nullable List<SentryThread> threads,
311-
final @Nullable List<DebugImage> debugImages) {
319+
final @Nullable List<DebugImage> debugImages,
320+
final @Nullable ArtContext artContext) {
312321
this.type = type;
313322
this.dump = dump;
314323
this.threads = threads;
315324
this.debugImages = debugImages;
325+
this.artContext = artContext;
316326
}
317327
}
318328
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package io.sentry.android.core.internal.threaddump;
2+
3+
import io.sentry.protocol.ArtContext;
4+
import org.jetbrains.annotations.NotNull;
5+
import org.jetbrains.annotations.Nullable;
6+
7+
/**
8+
* Parses ART runtime memory and GC metrics from ANR thread dump lines.
9+
*
10+
* @see <a href="https://android.googlesource.com/platform/art/+/master/runtime/gc/heap.cc#1282">ART
11+
* Heap::DumpGcCountRateHistogram</a>
12+
*/
13+
final class ArtContextParser {
14+
15+
private static final long KB = 1024;
16+
private static final long MB = 1024 * KB;
17+
private static final long GB = 1024 * MB;
18+
19+
private static final String FREE_MEMORY_PREFIX = "Free memory ";
20+
private static final String FREE_MEMORY_UNTIL_GC_PREFIX = "Free memory until GC ";
21+
private static final String FREE_MEMORY_UNTIL_OOME_PREFIX = "Free memory until OOME ";
22+
private static final String TOTAL_MEMORY_PREFIX = "Total memory ";
23+
private static final String MAX_MEMORY_PREFIX = "Max memory ";
24+
private static final String TOTAL_TIME_WAITING_FOR_GC_PREFIX =
25+
"Total time waiting for GC to complete: ";
26+
private static final String TOTAL_GC_COUNT_PREFIX = "Total GC count: ";
27+
private static final String TOTAL_GC_TIME_PREFIX = "Total GC time: ";
28+
private static final String TOTAL_BLOCKING_GC_COUNT_PREFIX = "Total blocking GC count: ";
29+
private static final String TOTAL_BLOCKING_GC_TIME_PREFIX = "Total blocking GC time: ";
30+
private static final String TOTAL_PRE_OOME_GC_COUNT_PREFIX = "Total pre-OOME GC count: ";
31+
32+
private @Nullable ArtContext artContext;
33+
34+
@Nullable
35+
ArtContext getArtContext() {
36+
return artContext;
37+
}
38+
39+
void parseLine(final @NotNull String text) {
40+
if (text.startsWith(FREE_MEMORY_UNTIL_OOME_PREFIX)) {
41+
getOrCreateArtContext()
42+
.setFreeMemoryUntilOome(
43+
parsePrettySize(text.substring(FREE_MEMORY_UNTIL_OOME_PREFIX.length())));
44+
} else if (text.startsWith(FREE_MEMORY_UNTIL_GC_PREFIX)) {
45+
getOrCreateArtContext()
46+
.setFreeMemoryUntilGc(
47+
parsePrettySize(text.substring(FREE_MEMORY_UNTIL_GC_PREFIX.length())));
48+
} else if (text.startsWith(FREE_MEMORY_PREFIX)) {
49+
getOrCreateArtContext()
50+
.setFreeMemory(parsePrettySize(text.substring(FREE_MEMORY_PREFIX.length())));
51+
} else if (text.startsWith(TOTAL_MEMORY_PREFIX)) {
52+
getOrCreateArtContext()
53+
.setTotalMemory(parsePrettySize(text.substring(TOTAL_MEMORY_PREFIX.length())));
54+
} else if (text.startsWith(MAX_MEMORY_PREFIX)) {
55+
getOrCreateArtContext()
56+
.setMaxMemory(parsePrettySize(text.substring(MAX_MEMORY_PREFIX.length())));
57+
} else if (text.startsWith(TOTAL_TIME_WAITING_FOR_GC_PREFIX)) {
58+
getOrCreateArtContext()
59+
.setGcWaitingTime(parseTimeMs(text.substring(TOTAL_TIME_WAITING_FOR_GC_PREFIX.length())));
60+
} else if (text.startsWith(TOTAL_GC_TIME_PREFIX)) {
61+
getOrCreateArtContext()
62+
.setGcTotalTime(parseTimeMs(text.substring(TOTAL_GC_TIME_PREFIX.length())));
63+
} else if (text.startsWith(TOTAL_GC_COUNT_PREFIX)) {
64+
getOrCreateArtContext()
65+
.setGcTotalCount(parseLongOrNull(text.substring(TOTAL_GC_COUNT_PREFIX.length())));
66+
} else if (text.startsWith(TOTAL_BLOCKING_GC_TIME_PREFIX)) {
67+
getOrCreateArtContext()
68+
.setGcBlockingTime(parseTimeMs(text.substring(TOTAL_BLOCKING_GC_TIME_PREFIX.length())));
69+
} else if (text.startsWith(TOTAL_BLOCKING_GC_COUNT_PREFIX)) {
70+
getOrCreateArtContext()
71+
.setGcBlockingCount(
72+
parseLongOrNull(text.substring(TOTAL_BLOCKING_GC_COUNT_PREFIX.length())));
73+
} else if (text.startsWith(TOTAL_PRE_OOME_GC_COUNT_PREFIX)) {
74+
getOrCreateArtContext()
75+
.setGcPreOomeCount(
76+
parseLongOrNull(text.substring(TOTAL_PRE_OOME_GC_COUNT_PREFIX.length())));
77+
}
78+
}
79+
80+
private @NotNull ArtContext getOrCreateArtContext() {
81+
if (artContext == null) {
82+
artContext = new ArtContext();
83+
}
84+
return artContext;
85+
}
86+
87+
/**
88+
* Matches Android's PrettySize output: number followed by unit with no space, e.g. "3107KB".
89+
*
90+
* <p>Counterpart to
91+
* https://cs.android.com/android/platform/superproject/+/android-latest-release:art/libartbase/base/utils.cc;l=232-251;drc=d0d3deb269b1e14de2ec2707815e38bc95de570c
92+
*/
93+
private @Nullable Long parsePrettySize(final @NotNull String sizeString) {
94+
final String trimmed = sizeString.trim();
95+
try {
96+
if (trimmed.endsWith("GB")) {
97+
return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * GB;
98+
} else if (trimmed.endsWith("MB")) {
99+
return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * MB;
100+
} else if (trimmed.endsWith("KB")) {
101+
return Long.parseLong(trimmed.substring(0, trimmed.length() - 2)) * KB;
102+
} else if (trimmed.endsWith("B")) {
103+
return Long.parseLong(trimmed.substring(0, trimmed.length() - 1));
104+
}
105+
} catch (NumberFormatException e) {
106+
return null;
107+
}
108+
return null;
109+
}
110+
111+
/**
112+
* Parses ART's PrettyDuration output and converts to milliseconds. Handles "s", "ms", "us", "ns"
113+
* suffixes and the bare "0" special case.
114+
*
115+
* @see <a
116+
* href="https://cs.android.com/android/platform/superproject/+/android-latest-release:art/libartbase/base/time_utils.cc;l=95-133;drc=16e1409f339b1318fe1cdce8462f089b3b0475e8">ART
117+
* PrettyDuration / FormatDuration</a>
118+
*/
119+
private static @Nullable Double parseTimeMs(final @NotNull String timeString) {
120+
final String trimmed = timeString.trim();
121+
try {
122+
if (trimmed.equals("0")) {
123+
return 0.0;
124+
}
125+
// Double.parseDouble is locale-independent (always uses '.' as decimal separator),
126+
// which matches the ART runtime output format.
127+
if (trimmed.endsWith("ms")) {
128+
return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2));
129+
} else if (trimmed.endsWith("ns")) {
130+
return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)) / 1_000_000.0;
131+
} else if (trimmed.endsWith("us")) {
132+
return Double.parseDouble(trimmed.substring(0, trimmed.length() - 2)) / 1_000.0;
133+
} else if (trimmed.endsWith("s")) {
134+
return Double.parseDouble(trimmed.substring(0, trimmed.length() - 1)) * 1_000.0;
135+
}
136+
} catch (NumberFormatException e) {
137+
return null;
138+
}
139+
return null;
140+
}
141+
142+
private static @Nullable Long parseLongOrNull(final @NotNull String value) {
143+
try {
144+
return Long.parseLong(value.trim());
145+
} catch (NumberFormatException e) {
146+
return null;
147+
}
148+
}
149+
}

sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.sentry.SentryOptions;
2424
import io.sentry.SentryStackTraceFactory;
2525
import io.sentry.android.core.internal.util.NativeEventUtils;
26+
import io.sentry.protocol.ArtContext;
2627
import io.sentry.protocol.DebugImage;
2728
import io.sentry.protocol.SentryStackFrame;
2829
import io.sentry.protocol.SentryStackTrace;
@@ -109,6 +110,8 @@ public class ThreadDumpParser {
109110

110111
private final @NotNull List<SentryThread> threads;
111112

113+
private final @NotNull ArtContextParser artContextParser = new ArtContextParser();
114+
112115
public ThreadDumpParser(final @NotNull SentryOptions options, final boolean isBackground) {
113116
this.options = options;
114117
this.isBackground = isBackground;
@@ -127,6 +130,11 @@ public List<SentryThread> getThreads() {
127130
return threads;
128131
}
129132

133+
@Nullable
134+
public ArtContext getArtContext() {
135+
return artContextParser.getArtContext();
136+
}
137+
130138
public void parse(final @NotNull Lines lines) {
131139

132140
final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher("");
@@ -148,6 +156,8 @@ public void parse(final @NotNull Lines lines) {
148156
if (thread != null) {
149157
threads.add(thread);
150158
}
159+
} else {
160+
artContextParser.parseLine(text);
151161
}
152162
}
153163
}

0 commit comments

Comments
 (0)