From 69f19d761930088ad717a0294e56649e80b2470b Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Thu, 20 Aug 2026 20:08:24 +0200 Subject: [PATCH 01/13] react-native: read Android ANR records from ApplicationExitInfo --- .../backtrace/library/AnrExitInfoReader.java | 136 +++++++++++ .../library/BacktraceReactNative.java | 11 + .../anr/ActivityManagerExitInfoProvider.java | 48 ++++ .../library/anr/AnrExitInfoState.java | 24 ++ .../anr/AppExitInfoDetailsExtractor.java | 88 +++++++ .../java/backtrace/library/anr/ExitInfo.java | 24 ++ .../library/anr/ExitInfoAdapter.java | 71 ++++++ .../library/anr/ExitInfoStackTraceParser.java | 219 ++++++++++++++++++ .../library/anr/ProcessExitInfoProvider.java | 9 + 9 files changed, 630 insertions(+) create mode 100644 packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/ActivityManagerExitInfoProvider.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/AppExitInfoDetailsExtractor.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfo.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoAdapter.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/ProcessExitInfoProvider.java diff --git a/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java b/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java new file mode 100644 index 00000000..1b39e37a --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java @@ -0,0 +1,136 @@ +package backtraceio.library; + +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.RequiresApi; + +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeArray; +import com.facebook.react.bridge.WritableNativeMap; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import backtraceio.library.anr.ActivityManagerExitInfoProvider; +import backtraceio.library.anr.AppExitInfoDetailsExtractor; +import backtraceio.library.anr.ExitInfo; +import backtraceio.library.anr.ExitInfoStackTraceParser; +import backtraceio.library.anr.ProcessExitInfoProvider; + +class AnrExitInfoReader { + private final static transient String LOG_TAG = AnrExitInfoReader.class.getSimpleName(); + + private static final int ALL_EXIT_RECORDS = 0; + + private final Context context; + private final ProcessExitInfoProvider exitInfoProvider; + + AnrExitInfoReader(Context context) { + this(context, new ActivityManagerExitInfoProvider(context)); + } + + AnrExitInfoReader(Context context, ProcessExitInfoProvider exitInfoProvider) { + this.context = context; + this.exitInfoProvider = exitInfoProvider; + } + + WritableArray read(long sinceEpochMillis) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return new WritableNativeArray(); + } + return readAnrRecords(sinceEpochMillis); + } + + @RequiresApi(Build.VERSION_CODES.R) + private WritableArray readAnrRecords(long sinceEpochMillis) { + WritableArray records = new WritableNativeArray(); + + List exitInfos = this.exitInfoProvider.getHistoricalExitInfo( + this.context.getPackageName(), 0, ALL_EXIT_RECORDS); + List supportedReasons = this.exitInfoProvider.getSupportedTypesOfExitInfo(); + + // oldest first, so a caller advancing a last-seen timestamp cannot skip a record + List ordered = new ArrayList<>(exitInfos); + Collections.reverse(ordered); + + for (ExitInfo exitInfo : ordered) { + if (!supportedReasons.contains(exitInfo.getReason())) { + continue; + } + if (exitInfo.getTimestamp() <= sinceEpochMillis) { + continue; + } + records.pushMap(describe(exitInfo)); + } + + return records; + } + + @RequiresApi(Build.VERSION_CODES.R) + private WritableMap describe(ExitInfo exitInfo) { + String stackTrace = AppExitInfoDetailsExtractor.getStackTraceInfo(exitInfo); + + WritableMap record = new WritableNativeMap(); + // putInt would overflow epoch millis + record.putDouble("timestamp", (double) exitInfo.getTimestamp()); + record.putString("message", AppExitInfoDetailsExtractor.getANRMessage(exitInfo)); + record.putMap("attributes", toWritableMap(AppExitInfoDetailsExtractor.getANRAttributes(exitInfo))); + record.putString("stackTrace", stackTrace); + record.putString("mainThreadStackTrace", formatMainThreadStackTrace(stackTrace)); + return record; + } + + private String formatMainThreadStackTrace(String stackTrace) { + if (stackTrace == null || stackTrace.isEmpty()) { + return null; + } + + StackTraceElement[] frames; + try { + Map parsed = ExitInfoStackTraceParser.parseANRStackTrace(stackTrace); + frames = ExitInfoStackTraceParser.parseMainThreadStackTrace(parsed); + } catch (Exception e) { + Log.e(LOG_TAG, "Could not parse the ANR stack trace", e); + return null; + } + + if (frames.length == 0) { + return null; + } + + // StackTraceElement.toString() matches what AndroidStackTraceConverter expects + StringBuilder formatted = new StringBuilder(); + for (StackTraceElement frame : frames) { + formatted.append(frame.toString()).append('\n'); + } + return formatted.toString(); + } + + private WritableMap toWritableMap(Map values) { + WritableMap map = new WritableNativeMap(); + for (Map.Entry entry : values.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof String) { + map.putString(key, (String) value); + } else if (value instanceof Integer) { + map.putInt(key, (Integer) value); + } else if (value instanceof Long || value instanceof Double || value instanceof Float) { + map.putDouble(key, ((Number) value).doubleValue()); + } else if (value instanceof Boolean) { + map.putBoolean(key, (Boolean) value); + } else if (value == null) { + map.putNull(key); + } else { + map.putString(key, value.toString()); + } + } + return map; + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java index f8b51b14..42b5b4a4 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java @@ -2,6 +2,7 @@ import androidx.annotation.NonNull; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; @@ -89,4 +90,14 @@ public void useAttributes(ReadableMap readableAttributes) { public void crash() { BacktraceBase.crash(); } + + @ReactMethod() + public void getAnrExitInfo(double sinceEpochMillis, Promise promise) { + try { + promise.resolve(new AnrExitInfoReader(this.context).read((long) sinceEpochMillis)); + } catch (Exception e) { + Log.d(this.NAME, "Could not read ANR exit info"); + promise.reject("backtrace_anr_exit_info", e); + } + } } diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ActivityManagerExitInfoProvider.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ActivityManagerExitInfoProvider.java new file mode 100644 index 00000000..47b36d32 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ActivityManagerExitInfoProvider.java @@ -0,0 +1,48 @@ +package backtraceio.library.anr; + +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ActivityManagerExitInfoProvider implements ProcessExitInfoProvider { + private final ActivityManager activityManager; + + public ActivityManagerExitInfoProvider(Context context) { + this.activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public List getHistoricalExitInfo(String packageName, int startIndex, int maxCount) { + if (activityManager == null) { + return Collections.emptyList(); + } + + List systemExitInfoList = + activityManager.getHistoricalProcessExitReasons(packageName, startIndex, maxCount); + + if (systemExitInfoList.isEmpty()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(systemExitInfoList.size()); + for (ApplicationExitInfo info : systemExitInfoList) { + result.add(new ExitInfoAdapter(info)); + } + + return result; + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public List getSupportedTypesOfExitInfo() { + return Collections.singletonList(ApplicationExitInfo.REASON_ANR); + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java new file mode 100644 index 00000000..ec0f95dc --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java @@ -0,0 +1,24 @@ +package backtraceio.library.anr; + +import android.content.Context; +import android.content.SharedPreferences; + +public class AnrExitInfoState { + private static final String PREFS_NAME = "ANR_APP_EXIT_INFO_STATE"; + private static final String TIMESTAMP_PREF_KEY = "LAST_ANR_TIMESTAMP"; + private static final long TIMESTAMP_DEFAULT = 0; + + private final SharedPreferences sharedPreferences; + + public AnrExitInfoState(Context context) { + this.sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + public void saveTimestamp(long value) { + this.sharedPreferences.edit().putLong(TIMESTAMP_PREF_KEY, value).apply(); + } + + public long getLastTimestamp() { + return this.sharedPreferences.getLong(TIMESTAMP_PREF_KEY, TIMESTAMP_DEFAULT); + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/AppExitInfoDetailsExtractor.java b/packages/react-native/android/src/main/java/backtrace/library/anr/AppExitInfoDetailsExtractor.java new file mode 100644 index 00000000..9789ec92 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/AppExitInfoDetailsExtractor.java @@ -0,0 +1,88 @@ +package backtraceio.library.anr; + +import android.app.ApplicationExitInfo; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.RequiresApi; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; + +public class AppExitInfoDetailsExtractor { + private static final String LOG_TAG = AppExitInfoDetailsExtractor.class.getSimpleName(); + + @RequiresApi(api = Build.VERSION_CODES.R) + public static HashMap getANRAttributes(ExitInfo appExitInfo) { + if (appExitInfo == null) { + return new HashMap<>(); + } + + final HashMap attributes = new HashMap<>(); + attributes.put("description", appExitInfo.getDescription()); + attributes.put("timestamp", getANRTimestamp(appExitInfo)); + attributes.put("reason-code", appExitInfo.getReason()); + attributes.put("reason", reasonCodeToDescription(appExitInfo.getReason())); + attributes.put("PID", appExitInfo.getPid()); + attributes.put("Importance", appExitInfo.getImportance()); + attributes.put("PSS", appExitInfo.getPss()); + attributes.put("RSS", appExitInfo.getRss()); + return attributes; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + public static String getANRMessage(ExitInfo appExitInfo) { + return "Application Not Responding" + " | " + "Description: " + + appExitInfo.getDescription() + " | " + "Timestamp: " + + getANRTimestamp(appExitInfo); + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private static String getANRTimestamp(ExitInfo appExitInfo) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); + return dateFormat.format(new Date(appExitInfo.getTimestamp())); + } + + @RequiresApi(api = Build.VERSION_CODES.R) + public static String getStackTraceInfo(ExitInfo exitInfo) { + InputStream traceStream = getStreamOrNull(exitInfo); + if (traceStream == null) { + Log.w(LOG_TAG, "Unexpected null trace stream"); + return null; + } + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(traceStream))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line).append("\n"); + } + } catch (Exception exception) { + Log.e(LOG_TAG, "Unexpected exception on getting stacktrace from exitInfo", exception); + return ""; + } + + return builder.toString(); + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private static InputStream getStreamOrNull(ExitInfo exitInfo) { + try { + return exitInfo.getTraceInputStream(); + } catch (IOException e) { + return null; + } + } + + private static String reasonCodeToDescription(int reasonCode) { + if (reasonCode == ApplicationExitInfo.REASON_ANR) { + return "anr"; + } + return "unsupported code"; + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfo.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfo.java new file mode 100644 index 00000000..ebd10ec8 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfo.java @@ -0,0 +1,24 @@ +package backtraceio.library.anr; + +import java.io.IOException; +import java.io.InputStream; + +public interface ExitInfo { + int getReason(); + + long getTimestamp(); + + String getDescription(); + + int getPid(); + + int getUid(); + + int getImportance(); + + long getPss(); + + long getRss(); + + InputStream getTraceInputStream() throws IOException; +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoAdapter.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoAdapter.java new file mode 100644 index 00000000..61c78a9a --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoAdapter.java @@ -0,0 +1,71 @@ +package backtraceio.library.anr; + +import android.app.ApplicationExitInfo; +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import java.io.IOException; +import java.io.InputStream; + +public class ExitInfoAdapter implements ExitInfo { + private final ApplicationExitInfo systemExitInfo; + + public ExitInfoAdapter(ApplicationExitInfo systemExitInfo) { + this.systemExitInfo = systemExitInfo; + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public int getReason() { + return systemExitInfo.getReason(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public long getTimestamp() { + return systemExitInfo.getTimestamp(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public String getDescription() { + return systemExitInfo.getDescription(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public int getPid() { + return systemExitInfo.getPid(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public int getUid() { + return systemExitInfo.getDefiningUid(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public int getImportance() { + return systemExitInfo.getImportance(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public long getPss() { + return systemExitInfo.getPss(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public long getRss() { + return systemExitInfo.getRss(); + } + + @Override + @RequiresApi(api = Build.VERSION_CODES.R) + public InputStream getTraceInputStream() throws IOException { + return systemExitInfo.getTraceInputStream(); + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java new file mode 100644 index 00000000..c6efec73 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java @@ -0,0 +1,219 @@ +package backtraceio.library.anr; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ExitInfoStackTraceParser { + private static final Pattern JAVA_FRAME_PATTERN = Pattern.compile("\\s*at (.*?)\\((.*?):(\\d+)\\)"); + private static final String MAIN_THREAD_NAME = "main"; + private static final int NATIVE_STACK_ELEMENTS_NUMBER = 6; + + static StackTraceElement parseFrame(String frame) { + StackTraceElement javaFrame = parseJavaFrame(frame); + if (javaFrame != null) { + return javaFrame; + } + + return parseNativeFrame(frame); + } + + static StackTraceElement parseNativeFrame(String frame) { + if (!frame.startsWith("native")) { + return null; + } + String[] parts = frame.split("\\s+", NATIVE_STACK_ELEMENTS_NUMBER); + + if (parts.length < NATIVE_STACK_ELEMENTS_NUMBER) { + return null; + } + + String address = parts[3]; + String library = parts[4]; + String funcName = parts[5]; + + return new StackTraceElement(library, funcName, "address: " + address, 0); + } + + static StackTraceElement parseJavaFrame(String frame) { + Matcher matcher = JAVA_FRAME_PATTERN.matcher(frame); + if (!matcher.find()) { + return null; + } + + String fullClassNameMethod = matcher.group(1); + String fileName = matcher.group(2); + int lineNumber = Integer.parseInt(matcher.group(3)); + + int lastDot = fullClassNameMethod.lastIndexOf('.'); + String className = (lastDot == -1) ? fullClassNameMethod : fullClassNameMethod.substring(0, lastDot); + String methodName = (lastDot == -1) ? "" : fullClassNameMethod.substring(lastDot + 1); + + return new StackTraceElement(className, methodName, fileName, lineNumber); + } + + public static StackTraceElement[] parseMainThreadStackTrace(Map parsedData) { + Map mainThreadInfo = (Map) parsedData.get("main_thread"); + + if (mainThreadInfo == null) { + return new StackTraceElement[0]; + } + + List stackFrames = (List) mainThreadInfo.get("stack_trace"); + + if (stackFrames == null) { + return new StackTraceElement[0]; + } + + List elements = new ArrayList<>(); + for (String frame : stackFrames) { + StackTraceElement element = parseFrame(frame); + if (element != null) { + elements.add(element); + } + } + return elements.toArray(new StackTraceElement[0]); + } + + public static Map parseANRStackTrace(String stackTrace) { + Map parsedData = new HashMap<>(); + + if (stackTrace == null || stackTrace.isEmpty()) { + return parsedData; + } + + parsedData.put("timestamp", parseTimestamp(stackTrace)); + + Pattern pidPattern = Pattern.compile("----- pid (\\d+) at"); + Matcher pidMatcher = pidPattern.matcher(stackTrace); + if (pidMatcher.find()) { + parsedData.put("pid", Integer.parseInt(pidMatcher.group(1))); + } + + Pattern cmdLinePattern = Pattern.compile("Cmd line: (.*)"); + Matcher cmdLineMatcher = cmdLinePattern.matcher(stackTrace); + if (cmdLineMatcher.find()) { + parsedData.put("command_line", cmdLineMatcher.group(1)); + } + + Pattern fingerprintPattern = Pattern.compile("Build fingerprint: '(.*?)'"); + Matcher fingerprintMatcher = fingerprintPattern.matcher(stackTrace); + if (fingerprintMatcher.find()) { + parsedData.put("build_fingerprint", fingerprintMatcher.group(1)); + } + + Pattern abiPattern = Pattern.compile("ABI: '(.*?)'"); + Matcher abiMatcher = abiPattern.matcher(stackTrace); + if (abiMatcher.find()) { + parsedData.put("abi", abiMatcher.group(1)); + } + + Pattern buildTypePattern = Pattern.compile("Build type: (.*)"); + Matcher buildTypeMatcher = buildTypePattern.matcher(stackTrace); + if (buildTypeMatcher.find()) { + parsedData.put("build_type", buildTypeMatcher.group(1)); + } + + Pattern heapPattern = Pattern.compile("Heap: (.*)"); + Matcher heapMatcher = heapPattern.matcher(stackTrace); + if (heapMatcher.find()) { + parsedData.put("heap_info", heapMatcher.group(1)); + } + + List> threads = parseThreadDumps(stackTrace); + parsedData.put("threads", threads); + + Map mainThreadInfo = getMainThreadInfo(threads); + parsedData.put("main_thread", mainThreadInfo); + + return parsedData; + } + + private static Object parseTimestamp(String stackTrace) { + Pattern timestampPattern = Pattern.compile("----- pid \\d+ at (.*?) -----"); + Matcher timestampMatcher = timestampPattern.matcher(stackTrace); + if (timestampMatcher.find()) { + return timestampMatcher.group(1); + } + return null; + } + + @Nullable + private static Map getMainThreadInfo(List> threads) { + Map mainThreadInfo = null; + for (Map thread : threads) { + if (MAIN_THREAD_NAME.equals(thread.get("name"))) { + mainThreadInfo = thread; + break; + } + } + return mainThreadInfo; + } + + private static Map parseThreadInformation(String threadDump) { + Map result = new HashMap<>(); + + Pattern headerPattern = Pattern.compile("\"([^\"]+)\"\\s*(daemon)?\\s*prio=(\\d+)\\s*tid=(\\d+)\\s*([^\\n]+)"); + Matcher headerMatcher = headerPattern.matcher(threadDump); + + if (headerMatcher.find()) { + result.put("name", headerMatcher.group(1)); + result.put("isDaemon", headerMatcher.group(2) != null); + result.put("prio", Integer.parseInt(headerMatcher.group(3))); + result.put("tid", Integer.parseInt(headerMatcher.group(4))); + result.put("status", headerMatcher.group(5).trim()); + } + + result.put("stack_trace", parseThreadStackTrace(threadDump)); + + return result; + } + + @NonNull + private static List parseThreadStackTrace(String threadDump) { + List stackTrace = new ArrayList<>(); + String[] lines = threadDump.split("\n"); + boolean isStackTrace = false; + + for (String line : lines) { + line = line.trim(); + + if (isStackTrace && (line.isEmpty() || line.startsWith("\""))) { + break; + } + + if (line.isEmpty() || line.startsWith("\"")) { + continue; + } + + if (line.startsWith("at ") || line.startsWith("native:")) { + isStackTrace = true; + stackTrace.add(line); + } + } + return stackTrace; + } + + public static List> parseThreadDumps(String input) { + List> threads = new ArrayList<>(); + + String regex = "\\\"(.*?)\\\" (daemon )?prio=(\\d+) tid=(\\d+) (\\w+)(.*)\\n(?s)((.*?\\n))(?=(?:\\n\\n)|$)"; + + Pattern threadStartPattern = Pattern.compile(regex, Pattern.MULTILINE); + Matcher threadMatcher = threadStartPattern.matcher(input); + + while (threadMatcher.find()) { + String threadDump = threadMatcher.group(); + Map threadInfo = parseThreadInformation(threadDump); + threads.add(threadInfo); + } + + return threads; + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ProcessExitInfoProvider.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ProcessExitInfoProvider.java new file mode 100644 index 00000000..fdc383ce --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ProcessExitInfoProvider.java @@ -0,0 +1,9 @@ +package backtraceio.library.anr; + +import java.util.List; + +public interface ProcessExitInfoProvider { + List getHistoricalExitInfo(String packageName, int startIndex, int maxCount); + + List getSupportedTypesOfExitInfo(); +} From bf898840e22032b3faaeff7f55852d3b2a70ec38 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 25 Aug 2026 16:37:56 +0200 Subject: [PATCH 02/13] react-native: add Android ANR detection with threshold and applicationExit modes --- .../backtrace/library/AnrExitInfoReader.java | 27 ++--- .../library/BacktraceAnrWatchdog.java | 73 ++++++++++++++ .../backtrace/library/ReactNativePackage.java | 1 + .../library/anr/AnrExitInfoState.java | 24 ----- .../backtrace/library/anr/AnrWatchdog.java | 68 +++++++++++++ .../library/anr/BacktraceThreadWatcher.java | 22 +++++ .../library/anr/StackFrameMapper.java | 56 +++++++++++ packages/react-native/src/BacktraceClient.ts | 25 +++++ .../src/BacktraceConfiguration.ts | 38 ++++++- packages/react-native/src/anr/AnrException.ts | 10 ++ packages/react-native/src/anr/AnrReporter.ts | 98 +++++++++++++++++++ .../src/anr/AnrWatchdogHandler.ts | 60 ++++++++++++ 12 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java delete mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/BacktraceThreadWatcher.java create mode 100644 packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java create mode 100644 packages/react-native/src/anr/AnrException.ts create mode 100644 packages/react-native/src/anr/AnrReporter.ts create mode 100644 packages/react-native/src/anr/AnrWatchdogHandler.ts diff --git a/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java b/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java index 1b39e37a..b4188af7 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java +++ b/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java @@ -21,6 +21,7 @@ import backtraceio.library.anr.ExitInfo; import backtraceio.library.anr.ExitInfoStackTraceParser; import backtraceio.library.anr.ProcessExitInfoProvider; +import backtraceio.library.anr.StackFrameMapper; class AnrExitInfoReader { private final static transient String LOG_TAG = AnrExitInfoReader.class.getSimpleName(); @@ -81,34 +82,26 @@ private WritableMap describe(ExitInfo exitInfo) { record.putString("message", AppExitInfoDetailsExtractor.getANRMessage(exitInfo)); record.putMap("attributes", toWritableMap(AppExitInfoDetailsExtractor.getANRAttributes(exitInfo))); record.putString("stackTrace", stackTrace); - record.putString("mainThreadStackTrace", formatMainThreadStackTrace(stackTrace)); + + StackTraceElement[] frames = parseMainThreadFrames(stackTrace); + if (frames.length > 0) { + record.putArray("mainThreadFrames", StackFrameMapper.toWritableFrames(frames)); + } return record; } - private String formatMainThreadStackTrace(String stackTrace) { + private StackTraceElement[] parseMainThreadFrames(String stackTrace) { if (stackTrace == null || stackTrace.isEmpty()) { - return null; + return new StackTraceElement[0]; } - StackTraceElement[] frames; try { Map parsed = ExitInfoStackTraceParser.parseANRStackTrace(stackTrace); - frames = ExitInfoStackTraceParser.parseMainThreadStackTrace(parsed); + return ExitInfoStackTraceParser.parseMainThreadStackTrace(parsed); } catch (Exception e) { Log.e(LOG_TAG, "Could not parse the ANR stack trace", e); - return null; - } - - if (frames.length == 0) { - return null; - } - - // StackTraceElement.toString() matches what AndroidStackTraceConverter expects - StringBuilder formatted = new StringBuilder(); - for (StackTraceElement frame : frames) { - formatted.append(frame.toString()).append('\n'); + return new StackTraceElement[0]; } - return formatted.toString(); } private WritableMap toWritableMap(Map values) { diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java new file mode 100644 index 00000000..12845058 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java @@ -0,0 +1,73 @@ +package backtraceio.library; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeMap; +import com.facebook.react.module.annotations.ReactModule; + +import backtraceio.library.anr.AnrWatchdog; +import backtraceio.library.anr.StackFrameMapper; + +@ReactModule(name = BacktraceAnrWatchdog.NAME) +public class BacktraceAnrWatchdog extends ReactContextBaseJavaModule { + public static final String NAME = "BacktraceAnrWatchdog"; + public static final String ANR_DETECTED_EVENT = "BacktraceAnrDetected"; + + private AnrWatchdog watchdog; + + public BacktraceAnrWatchdog(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + @NonNull + public String getName() { + return NAME; + } + + @ReactMethod() + public void start(int timeout, boolean debug) { + if (this.watchdog != null) { + return; + } + + this.watchdog = new AnrWatchdog( + timeout > 0 ? timeout : AnrWatchdog.DEFAULT_ANR_TIMEOUT, + debug, + this::emitAnrDetected); + } + + @ReactMethod() + public void stop() { + if (this.watchdog == null) { + return; + } + + this.watchdog.stopMonitoring(); + this.watchdog = null; + } + + @ReactMethod() + public void addListener(String eventName) {} + + @ReactMethod() + public void removeListeners(Integer count) {} + + private void emitAnrDetected(StackTraceElement[] mainThreadFrames) { + ReactApplicationContext context = getReactApplicationContext(); + if (!context.hasActiveReactInstance()) { + return; + } + + WritableMap event = new WritableNativeMap(); + event.putString("stackTrace", StackFrameMapper.toFormattedString(mainThreadFrames)); + event.putArray("frames", StackFrameMapper.toWritableFrames(mainThreadFrames)); + + // getJSModule(RCTDeviceEventEmitter) drops events silently in bridgeless mode + context.emitDeviceEvent(ANR_DETECTED_EVENT, event); + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/ReactNativePackage.java b/packages/react-native/android/src/main/java/backtrace/library/ReactNativePackage.java index 19e4bb54..aa15d892 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/ReactNativePackage.java +++ b/packages/react-native/android/src/main/java/backtrace/library/ReactNativePackage.java @@ -23,6 +23,7 @@ public List createNativeModules(@NonNull ReactApplicationContext r modules.add(new backtraceio.library.MemoryInformationAttributeProvider(reactContext)); modules.add(new backtraceio.library.ProcessAttributeProvider(reactContext)); modules.add(new backtraceio.library.BacktraceAndroidBackgroundUnhandledExceptionHandler(reactContext)); + modules.add(new backtraceio.library.BacktraceAnrWatchdog(reactContext)); modules.add(new backtrace.library.BacktraceFileSystemProvider(reactContext)); modules.add(new backtrace.library.BacktraceDirectoryProvider(reactContext)); diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java deleted file mode 100644 index ec0f95dc..00000000 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrExitInfoState.java +++ /dev/null @@ -1,24 +0,0 @@ -package backtraceio.library.anr; - -import android.content.Context; -import android.content.SharedPreferences; - -public class AnrExitInfoState { - private static final String PREFS_NAME = "ANR_APP_EXIT_INFO_STATE"; - private static final String TIMESTAMP_PREF_KEY = "LAST_ANR_TIMESTAMP"; - private static final long TIMESTAMP_DEFAULT = 0; - - private final SharedPreferences sharedPreferences; - - public AnrExitInfoState(Context context) { - this.sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - } - - public void saveTimestamp(long value) { - this.sharedPreferences.edit().putLong(TIMESTAMP_PREF_KEY, value).apply(); - } - - public long getLastTimestamp() { - return this.sharedPreferences.getLong(TIMESTAMP_PREF_KEY, TIMESTAMP_DEFAULT); - } -} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java new file mode 100644 index 00000000..a5426b71 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java @@ -0,0 +1,68 @@ +package backtraceio.library.anr; + +import android.os.Debug; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +public class AnrWatchdog extends Thread { + public interface OnAnrDetected { + void onAnrDetected(StackTraceElement[] mainThreadFrames); + } + + public static final int DEFAULT_ANR_TIMEOUT = 5000; + + private static final String LOG_TAG = AnrWatchdog.class.getSimpleName(); + + private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + private final int timeout; + private final boolean debug; + private final OnAnrDetected listener; + + private volatile boolean shouldStop = false; + + public AnrWatchdog(int timeout, boolean debug, OnAnrDetected listener) { + this.timeout = timeout; + this.debug = debug; + this.listener = listener; + this.start(); + } + + @Override + public void run() { + if (this.debug && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) { + Log.w(LOG_TAG, "Detected a debugger connection. ANR watchdog is disabled"); + return; + } + + boolean reported = false; + while (!this.shouldStop && !isInterrupted()) { + final BacktraceThreadWatcher threadWatcher = new BacktraceThreadWatcher(); + this.mainThreadHandler.post(threadWatcher::tickCounter); + + try { + Thread.sleep(this.timeout); + } catch (InterruptedException e) { + return; + } + threadWatcher.tickPrivateCounter(); + + if (threadWatcher.getCounter() == threadWatcher.getPrivateCounter()) { + reported = false; + continue; + } + + // one report per hang, not one per polling interval + if (reported) { + continue; + } + reported = true; + + this.listener.onAnrDetected(Looper.getMainLooper().getThread().getStackTrace()); + } + } + + public void stopMonitoring() { + this.shouldStop = true; + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/BacktraceThreadWatcher.java b/packages/react-native/android/src/main/java/backtrace/library/anr/BacktraceThreadWatcher.java new file mode 100644 index 00000000..071588e5 --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/BacktraceThreadWatcher.java @@ -0,0 +1,22 @@ +package backtraceio.library.anr; + +public class BacktraceThreadWatcher { + private int counter; + private int privateCounter; + + public void tickPrivateCounter() { + privateCounter++; + } + + public int getPrivateCounter() { + return privateCounter; + } + + public synchronized int getCounter() { + return counter; + } + + public synchronized void tickCounter() { + counter++; + } +} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java b/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java new file mode 100644 index 00000000..5899072d --- /dev/null +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java @@ -0,0 +1,56 @@ +package backtraceio.library.anr; + +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeArray; +import com.facebook.react.bridge.WritableNativeMap; + +public class StackFrameMapper { + private static final String NATIVE_FILE_PREFIX = "address: "; + private static final String UNKNOWN_LIBRARY = "unknown"; + + public static WritableArray toWritableFrames(StackTraceElement[] frames) { + WritableArray result = new WritableNativeArray(); + for (StackTraceElement frame : frames) { + result.pushMap(toWritableFrame(frame)); + } + return result; + } + + public static String toFormattedString(StackTraceElement[] frames) { + StringBuilder formatted = new StringBuilder(); + for (StackTraceElement frame : frames) { + formatted.append(frame.toString()).append('\n'); + } + return formatted.toString(); + } + + private static WritableMap toWritableFrame(StackTraceElement frame) { + WritableMap map = new WritableNativeMap(); + + String fileName = frame.getFileName(); + if (fileName != null && fileName.startsWith(NATIVE_FILE_PREFIX)) { + // ExitInfoStackTraceParser.parseNativeFrame stores library as the class + // name, the symbol as the method name and the address as the file name + map.putString("funcName", frame.getMethodName()); + map.putString("library", frame.getClassName()); + return map; + } + + String className = frame.getClassName(); + map.putString("funcName", className.isEmpty() + ? frame.getMethodName() + : className + "." + frame.getMethodName()); + + if (frame.isNativeMethod()) { + map.putString("library", "Native"); + } else { + map.putString("library", fileName != null ? fileName : UNKNOWN_LIBRARY); + } + + if (frame.getLineNumber() > 0) { + map.putInt("line", frame.getLineNumber()); + } + return map; + } +} diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index f7acd98b..d7600d20 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -9,6 +9,8 @@ import { type DebugIdContainer, } from '@backtrace/sdk-core'; import { NativeModules, Platform } from 'react-native'; +import { AnrReporter } from './anr/AnrReporter'; +import { AnrWatchdogHandler } from './anr/AnrWatchdogHandler'; import { type BacktraceConfiguration } from './BacktraceConfiguration'; import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage'; import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; @@ -24,6 +26,7 @@ import { type FileSystem } from './storage/FileSystem'; export class BacktraceClient extends BacktraceCoreClient { private _crashReporter?: CrashReporter; + private _anrWatchdogHandler?: AnrWatchdogHandler; private readonly _exceptionHandler: ExceptionHandler = generateUnhandledExceptionHandler(); public crash(): void { @@ -81,6 +84,7 @@ export class BacktraceClient extends BacktraceCoreClient ); this._crashReporter = this.initializeNativeCrashReporter(); + this.reportApplicationNotResponding(); } finally { lockId && this.sessionFiles?.unlockPreviousSessions(lockId); } @@ -88,6 +92,7 @@ export class BacktraceClient extends BacktraceCoreClient public dispose(): void { this._exceptionHandler.dispose(); + this._anrWatchdogHandler?.dispose(); this._crashReporter?.dispose(); super.dispose(); } @@ -133,6 +138,26 @@ export class BacktraceClient extends BacktraceCoreClient } } + private reportApplicationNotResponding(): void { + const anr = this.options.anr; + if (!anr?.enable) { + return; + } + + if (anr.type === 'applicationExit') { + const fileSystem = this.fileSystem as FileSystem | undefined; + if (!fileSystem) { + return; + } + + AnrReporter.create(fileSystem)?.report(this); + return; + } + + this._anrWatchdogHandler = AnrWatchdogHandler.create(); + this._anrWatchdogHandler?.start(this, anr.timeout ?? 0, anr.debug ?? false); + } + private initializeNativeCrashReporter(): CrashReporter | undefined { if (!this.options.database?.enable) { return; diff --git a/packages/react-native/src/BacktraceConfiguration.ts b/packages/react-native/src/BacktraceConfiguration.ts index 46f3ba97..052b4755 100644 --- a/packages/react-native/src/BacktraceConfiguration.ts +++ b/packages/react-native/src/BacktraceConfiguration.ts @@ -1,2 +1,38 @@ import { type BacktraceConfiguration as SdkConfiguration } from '@backtrace/sdk-core'; -export interface BacktraceConfiguration extends SdkConfiguration {} + +export type BacktraceAnrType = 'threshold' | 'applicationExit'; + +export interface BacktraceAnrConfiguration { + /** + * Determines if Application Not Responding detection is enabled. Android only. + * By default the value is set to false. + */ + enable?: boolean; + + /** + * Detection mechanism. `threshold` watches the main thread and reports as soon as it is + * blocked, on any Android version. `applicationExit` reports ANRs the system recorded, + * on the next application start, and requires API 30 or above. + * By default the value is set to `threshold`. + */ + type?: BacktraceAnrType; + + /** + * Time in milliseconds the main thread must be blocked before an ANR is reported. + * Applies to the `threshold` type only. By default the value is set to 5000. + */ + timeout?: number; + + /** + * When true, detection is disabled while a debugger is attached. + * Applies to the `threshold` type only. By default the value is set to false. + */ + debug?: boolean; +} + +export interface BacktraceConfiguration extends SdkConfiguration { + /** + * Application Not Responding settings + */ + anr?: BacktraceAnrConfiguration; +} diff --git a/packages/react-native/src/anr/AnrException.ts b/packages/react-native/src/anr/AnrException.ts new file mode 100644 index 00000000..a5c02e00 --- /dev/null +++ b/packages/react-native/src/anr/AnrException.ts @@ -0,0 +1,10 @@ +export class AnrException extends Error { + public readonly name = 'Hang'; + + constructor( + public readonly message: string, + public readonly stack: string, + ) { + super(message); + } +} diff --git a/packages/react-native/src/anr/AnrReporter.ts b/packages/react-native/src/anr/AnrReporter.ts new file mode 100644 index 00000000..a85e060c --- /dev/null +++ b/packages/react-native/src/anr/AnrReporter.ts @@ -0,0 +1,98 @@ +import { BacktraceReport, type BacktraceStackFrame } from '@backtrace/sdk-core'; +import { NativeModules, Platform } from 'react-native'; +import type { BacktraceClient } from '../BacktraceClient'; +import { DebuggerHelper } from '../common/DebuggerHelper'; +import type { FileSystem } from '../storage/FileSystem'; +import { AnrException } from './AnrException'; + +export interface AnrExitInfoRecord { + timestamp: number; + message: string; + attributes: Record; + stackTrace?: string; + mainThreadFrames?: BacktraceStackFrame[]; +} + +export class AnrReporter { + private static readonly MarkerFileName = 'backtrace-anr-marker'; + + private constructor( + private readonly _fileSystem: FileSystem, + private readonly _markerPath: string, + ) {} + + public static create(fileSystem: FileSystem): AnrReporter | undefined { + if (Platform.OS !== 'android') { + return undefined; + } + + if (!DebuggerHelper.isNativeBridgeEnabled()) { + return undefined; + } + + if (!NativeModules.BacktraceReactNative?.getAnrExitInfo) { + return undefined; + } + + const applicationDirectory = fileSystem.applicationDirectory(); + if (!applicationDirectory) { + return undefined; + } + + return new AnrReporter(fileSystem, `${applicationDirectory}/${AnrReporter.MarkerFileName}`); + } + + public async report(client: BacktraceClient): Promise { + try { + const records: AnrExitInfoRecord[] = await NativeModules.BacktraceReactNative.getAnrExitInfo( + await this.readMarker(), + ); + + // records arrive oldest first, so stopping on failure leaves the rest for the next launch + for (const record of records) { + if (!record.mainThreadFrames?.length) { + // no groupable stack, but still mark it seen or it is re-read on every launch + await this.writeMarker(record.timestamp); + continue; + } + + const result = await client.send(this.buildReport(record)); + if (result.status !== 'Ok') { + return; + } + + await this.writeMarker(record.timestamp); + } + } catch { + return; + } + } + + private buildReport(record: AnrExitInfoRecord): BacktraceReport { + const report = new BacktraceReport( + new AnrException(record.message, record.stackTrace ?? ''), + { + ...record.attributes, + 'error.type': 'Hang', + 'ANR stacktrace': record.stackTrace ?? '', + }, + [], + ); + + report.addStackTrace('main', record.mainThreadFrames as BacktraceStackFrame[]); + return report; + } + + private async readMarker(): Promise { + if (!(await this._fileSystem.exists(this._markerPath))) { + return 0; + } + + const timestamp = parseInt(await this._fileSystem.readFile(this._markerPath), 10); + return Number.isFinite(timestamp) ? timestamp : 0; + } + + private writeMarker(timestamp: number): Promise { + return this._fileSystem.writeFile(this._markerPath, timestamp.toString()); + } +} diff --git a/packages/react-native/src/anr/AnrWatchdogHandler.ts b/packages/react-native/src/anr/AnrWatchdogHandler.ts new file mode 100644 index 00000000..9c6bd8fd --- /dev/null +++ b/packages/react-native/src/anr/AnrWatchdogHandler.ts @@ -0,0 +1,60 @@ +import { BacktraceReport, type BacktraceStackFrame } from '@backtrace/sdk-core'; +import { NativeEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native'; +import type { BacktraceClient } from '../BacktraceClient'; +import { DebuggerHelper } from '../common/DebuggerHelper'; +import { AnrException } from './AnrException'; + +const AnrDetectedEvent = 'BacktraceAnrDetected'; + +interface AnrDetectedPayload { + stackTrace: string; + frames: BacktraceStackFrame[]; +} + +export class AnrWatchdogHandler { + private _subscription?: EmitterSubscription; + + private constructor(private readonly _watchdog: NonNullable) {} + + public static create(): AnrWatchdogHandler | undefined { + if (Platform.OS !== 'android') { + return undefined; + } + + if (!DebuggerHelper.isNativeBridgeEnabled()) { + return undefined; + } + + const watchdog = NativeModules.BacktraceAnrWatchdog; + if (!watchdog?.start) { + return undefined; + } + + return new AnrWatchdogHandler(watchdog); + } + + public start(client: BacktraceClient, timeout: number, debug: boolean): void { + this._subscription = new NativeEventEmitter(this._watchdog).addListener( + AnrDetectedEvent, + (payload: AnrDetectedPayload) => { + client.breadcrumbs?.info('ANR detected - thread is blocked'); + + const report = new BacktraceReport( + new AnrException('Application Not Responding | Blocked thread detected', payload.stackTrace), + { 'error.type': 'Hang' }, + [], + ); + report.addStackTrace('main', payload.frames); + client.send(report); + }, + ); + + this._watchdog.start(timeout, debug); + } + + public dispose(): void { + this._subscription?.remove(); + this._subscription = undefined; + this._watchdog.stop?.(); + } +} From 55e10b64a0441a6d8c32041b7cb88668f9d5903c Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 25 Aug 2026 16:42:31 +0200 Subject: [PATCH 03/13] react-native: example duplicate react-native bundling, add real ANR trigger --- .../main/java/com/reactnative/ErrorGenerator.java | 12 ++++++++++++ examples/sdk/reactNative/index.js | 3 +++ examples/sdk/reactNative/metro.config.js | 10 ++++++++++ .../sdk/reactNative/src/actions/android/action.ts | 6 +----- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/ErrorGenerator.java b/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/ErrorGenerator.java index e019b13a..b56114fb 100644 --- a/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/ErrorGenerator.java +++ b/examples/sdk/reactNative/android/app/src/main/java/com/reactnative/ErrorGenerator.java @@ -8,6 +8,8 @@ import java.io.FileReader; import java.io.IOException; +import android.os.Handler; +import android.os.Looper; import android.util.Log; public class ErrorGenerator extends ReactContextBaseJavaModule { @@ -25,6 +27,16 @@ public void throwError() throws IOException { readUserConfiguration(); } + @ReactMethod + public void blockMainThread(int durationMs) { + new Handler(Looper.getMainLooper()).post(() -> { + try { + Thread.sleep(durationMs); + } catch (InterruptedException ignored) { + } + }); + } + private void readUserConfiguration() throws IOException { // I know for sure this file is there (spoiler alert, it's not) File mConfiguration = new File("configuration.json"); diff --git a/examples/sdk/reactNative/index.js b/examples/sdk/reactNative/index.js index 79ca62ca..c54a45b2 100644 --- a/examples/sdk/reactNative/index.js +++ b/examples/sdk/reactNative/index.js @@ -15,6 +15,9 @@ BacktraceClient.initialize({ prop2: 123, }, }, + anr: { + enable: true, + }, database: { enable: true, captureNativeCrashes: true, diff --git a/examples/sdk/reactNative/metro.config.js b/examples/sdk/reactNative/metro.config.js index e4a564eb..6774a96b 100644 --- a/examples/sdk/reactNative/metro.config.js +++ b/examples/sdk/reactNative/metro.config.js @@ -9,6 +9,16 @@ const backtraceSourceMapProcessor = require('@backtrace/react-native/scripts/pro * @type {import('metro-config').MetroConfig} */ const config = { + resolver: { + blockList: [ + // a second react-native copy splits the JS event registry from the native one + new RegExp(path.resolve(__dirname, '../../../packages/react-native/node_modules/react-native').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*'), + new RegExp(path.resolve(__dirname, '../../../node_modules/react-native').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*'), + ], + extraNodeModules: { + 'react-native': path.resolve(__dirname, 'node_modules/react-native'), + }, + }, watchFolders: [ path.resolve('../../../packages/react-native'), path.resolve('../../../packages/react-native/node_modules'), diff --git a/examples/sdk/reactNative/src/actions/android/action.ts b/examples/sdk/reactNative/src/actions/android/action.ts index 05f43165..6476cb33 100644 --- a/examples/sdk/reactNative/src/actions/android/action.ts +++ b/examples/sdk/reactNative/src/actions/android/action.ts @@ -13,11 +13,7 @@ export const actions: DemoAction[] = __DEV__ throw new Error('Native modules are not enabled.'); } - await new Promise((res) => { - setTimeout(() => { - res(); - }, 1000); - }); + errorGenerator.blockMainThread(10000); }, }, { From 7ae5794eefbac72d167615f263f63be597e48938 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 25 Aug 2026 17:41:05 +0200 Subject: [PATCH 04/13] react-native: add ANR tests, CI unit-test step, README section --- .github/workflows/android.yml | 4 + packages/react-native/README.md | 36 ++ packages/react-native/android/build.gradle | 1 + .../library/anr/StackFrameMapper.java | 3 +- .../anr/ExitInfoStackTraceParserTest.java | 150 +++++ .../resources/anrAppExitInfoStacktrace.txt | 521 ++++++++++++++++++ .../tests/anrReporterTests.spec.ts | 128 +++++ 7 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java create mode 100644 packages/react-native/android/src/test/resources/anrAppExitInfoStacktrace.txt create mode 100644 packages/react-native/tests/anrReporterTests.spec.ts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a26ff514..375b9736 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -69,6 +69,10 @@ jobs: - name: Inject the CI crash driver into the example entry point run: cat .github/scripts/ci-crash-driver.js >> examples/sdk/reactNative/index.js + - name: Run SDK unit tests + working-directory: examples/sdk/reactNative/android + run: ./gradlew :backtrace_react-native:testDebugUnitTest -PnewArchEnabled=${{ matrix.new-arch }} --console=plain + - name: Build example app working-directory: examples/sdk/reactNative/android run: ./gradlew assembleRelease -PbtCiDebuggable -PnewArchEnabled=${{ matrix.new-arch }} -x uploadSourceMapsToBacktrace --console=plain diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 0c5382b7..1ab69f85 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -19,6 +19,7 @@ and easy, after which you can explore the rich set of Backtrace features. - [Application Stability Metrics](#application-stability-metrics) - [Metrics Configuration](#metrics-configuration) - [Metrics Usage](#metrics-usage) + - [ANR Detection](#anr-detection) - [Offline Database support](#offline-database-support) - [Database Configuration](#database-configuration) - [Native crash support](#native-crash-support) @@ -291,6 +292,41 @@ client.metrics?.send(); --- +### ANR Detection + +The Backtrace react-native SDK can detect Application Not Responding errors on Android and report them with the +`Hang` error type. The report carries the main thread stack trace from the moment the hang was observed. + +Two detection mechanisms are available: + +- `threshold` monitors the main thread from a separate thread. If the main thread remains unresponsive for longer + than the configured timeout (default: 5 seconds), an ANR is reported. It works on every Android version and + reports while the application is still hung, so a report can be lost when the system kills the process before + the upload finishes. +- `applicationExit` retrieves the ANRs the system recorded for previous runs of the process from + `ApplicationExitInfo` and reports them on the next application start, including the thread dump the system + captured when it declared the ANR. It requires API 30 or above and survives the process kill. Reported records + are remembered, so each ANR is reported once. + +```ts +const options: BacktraceConfiguration = { + url: SUBMISSION_URL, + anr: { + enable: true, + type: 'threshold', + }, +}; +``` + +| Option Name | Type | Description | Default | Required? | +| ----------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------ | +| `enable` | Boolean | Determines if ANR detection is enabled. | `false` |
  • - [ ]
| +| `type` | `'threshold'` \| `'applicationExit'` | Detection mechanism. | `threshold` |
  • - [ ]
| +| `timeout` | Number | Time in milliseconds the main thread stays blocked before an ANR is reported. Applies to the `threshold` type only. | `5000` |
  • - [ ]
| +| `debug` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `threshold` type only. | `false` |
  • - [ ]
| + +--- + ### Offline database support The Backtrace react-native SDK can cache generated reports and crashes to local disk before sending them to Backtrace. diff --git a/packages/react-native/android/build.gradle b/packages/react-native/android/build.gradle index bb72b403..eb640544 100644 --- a/packages/react-native/android/build.gradle +++ b/packages/react-native/android/build.gradle @@ -81,6 +81,7 @@ dependencies { // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" + testImplementation "junit:junit:4.13.2" } if (isNewArchitectureEnabled()) { diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java b/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java index 5899072d..6f334217 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java @@ -30,8 +30,7 @@ private static WritableMap toWritableFrame(StackTraceElement frame) { String fileName = frame.getFileName(); if (fileName != null && fileName.startsWith(NATIVE_FILE_PREFIX)) { - // ExitInfoStackTraceParser.parseNativeFrame stores library as the class - // name, the symbol as the method name and the address as the file name + // parseNativeFrame stores the symbol in the method name and the library in the class name map.putString("funcName", frame.getMethodName()); map.putString("library", frame.getClassName()); return map; diff --git a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java new file mode 100644 index 00000000..250ac271 --- /dev/null +++ b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java @@ -0,0 +1,150 @@ +package backtraceio.library.anr; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class ExitInfoStackTraceParserTest { + private static final String ANR_APPEXIT_STACKTRACE_FILE = "anrAppExitInfoStacktrace.txt"; + + @Test + public void parseFrameJava() { + // GIVEN + String frame = "at backtraceio.backtraceio.MainActivity.handledException(MainActivity.java:157)"; + // WHEN + StackTraceElement stackTraceElement = ExitInfoStackTraceParser.parseFrame(frame); + // THEN + assertEquals("backtraceio.backtraceio.MainActivity", stackTraceElement.getClassName()); + assertEquals("MainActivity.java", stackTraceElement.getFileName()); + assertEquals(157, stackTraceElement.getLineNumber()); + assertEquals("handledException", stackTraceElement.getMethodName()); + } + + @Test + public void parseFrameNative() { + // GIVEN + String frame = + "native: #19 pc 00630008 /apex/com.android.art/lib/libart.so (art::InvokeMethod(art::ScopedObjectAccessAlreadyRunnable const&, _jobject*, _jobject*, _jobject*, unsigned int)+1464)"; + // WHEN + StackTraceElement stackTraceElement = ExitInfoStackTraceParser.parseFrame(frame); + // THEN + assertEquals("/apex/com.android.art/lib/libart.so", stackTraceElement.getClassName()); + assertEquals("address: 00630008", stackTraceElement.getFileName()); + assertEquals(0, stackTraceElement.getLineNumber()); + assertEquals( + "(art::InvokeMethod(art::ScopedObjectAccessAlreadyRunnable const&, _jobject*, _jobject*, _jobject*, unsigned int)+1464)", + stackTraceElement.getMethodName()); + } + + @Test + public void parseAnrStackTrace() { + // GIVEN + String anrStacktraceString = readResource(ANR_APPEXIT_STACKTRACE_FILE); + + // WHEN + Map anrStacktrace = ExitInfoStackTraceParser.parseANRStackTrace(anrStacktraceString); + + // THEN + assertNotNull(anrStacktrace); + assertNotNull(anrStacktrace.get("main_thread")); + assertEquals("x86", anrStacktrace.get("abi")); + assertEquals("74% free, 6892KB/25MB; 138095 objects", anrStacktrace.get("heap_info")); + assertEquals( + "google/sdk_gphone_x86/generic_x86_arm:11/RSR1.201013.001/6903271:user/release-keys", + anrStacktrace.get("build_fingerprint")); + assertEquals("optimized", anrStacktrace.get("build_type")); + assertEquals("backtraceio.backtraceio", anrStacktrace.get("command_line")); + assertEquals("2025-03-27 21:02:38", anrStacktrace.get("timestamp")); + assertEquals(9207, anrStacktrace.get("pid")); + + // THEN THREADS + List> threads = (List>) anrStacktrace.get("threads"); + assertEquals(20, threads.size()); + + Map customThread4 = threads.get(18); + List thread4StackTrace = (List) customThread4.get("stack_trace"); + + assertEquals("Thread-4", threads.get(18).get("name")); + assertEquals("at java.lang.Thread.sleep(Native method)", thread4StackTrace.get(0)); + assertEquals("at java.lang.Thread.sleep(Thread.java:442)", thread4StackTrace.get(1)); + assertEquals("at java.lang.Thread.sleep(Thread.java:358)", thread4StackTrace.get(2)); + assertEquals( + "at backtraceio.library.watchdog.BacktraceANRHandlerWatchdog.run(BacktraceANRHandlerWatchdog.java:118)", + thread4StackTrace.get(3)); + + // THEN MAIN THREAD + Map mainThread = (Map) anrStacktrace.get("main_thread"); + assertEquals(5, mainThread.get("prio")); + assertEquals(1, mainThread.get("tid")); + assertNull(mainThread.get("daemon")); + + ArrayList stackTrace = (ArrayList) mainThread.get("stack_trace"); + assertEquals(36, stackTrace.size()); + + assertEquals( + "native: #20 pc 005886a0 /apex/com.android.art/lib/libart.so (art::Method_invoke(_JNIEnv*, _jobject*, _jobject*, _jobjectArray*)+80)", + stackTrace.get(20)); + assertEquals( + "at androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:468)", + stackTrace.get(24)); + } + + @Test + public void parseAnrMainThreadStackTrace() { + // GIVEN + String anrStacktraceString = readResource(ANR_APPEXIT_STACKTRACE_FILE); + Map anrStacktrace = ExitInfoStackTraceParser.parseANRStackTrace(anrStacktraceString); + + // WHEN + StackTraceElement[] anrMainThreadStacktrace = ExitInfoStackTraceParser.parseMainThreadStackTrace(anrStacktrace); + + // THEN + assertEquals(33, anrMainThreadStacktrace.length); + + assertEquals("(__kernel_vsyscall+7)", anrMainThreadStacktrace[0].getMethodName()); + assertEquals(0, anrMainThreadStacktrace[0].getLineNumber()); + assertEquals("address: 00000b97", anrMainThreadStacktrace[0].getFileName()); + assertEquals("[vdso]", anrMainThreadStacktrace[0].getClassName()); + + assertEquals( + "(art::interpreter::EnterInterpreterFromEntryPoint(art::Thread*, art::CodeItemDataAccessor const&, art::ShadowFrame*)+176)", + anrMainThreadStacktrace[14].getMethodName()); + assertEquals(0, anrMainThreadStacktrace[14].getLineNumber()); + assertEquals("address: 00379b00", anrMainThreadStacktrace[14].getFileName()); + assertEquals("/apex/com.android.art/lib/libart.so", anrMainThreadStacktrace[14].getClassName()); + + assertEquals("handledException", anrMainThreadStacktrace[21].getMethodName()); + assertEquals(157, anrMainThreadStacktrace[21].getLineNumber()); + assertEquals("MainActivity.java", anrMainThreadStacktrace[21].getFileName()); + assertEquals("backtraceio.backtraceio.MainActivity", anrMainThreadStacktrace[21].getClassName()); + } + + private static String readResource(String fileName) { + ClassLoader classLoader = ExitInfoStackTraceParserTest.class.getClassLoader(); + InputStream inputStream = classLoader.getResourceAsStream(fileName); + assertNotNull(inputStream); + + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + if (reader.ready()) { + builder.append('\n'); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + return builder.toString(); + } +} diff --git a/packages/react-native/android/src/test/resources/anrAppExitInfoStacktrace.txt b/packages/react-native/android/src/test/resources/anrAppExitInfoStacktrace.txt new file mode 100644 index 00000000..43b52ead --- /dev/null +++ b/packages/react-native/android/src/test/resources/anrAppExitInfoStacktrace.txt @@ -0,0 +1,521 @@ +----- pid 9207 at 2025-03-27 21:02:38 ----- +Cmd line: backtraceio.backtraceio +Build fingerprint: 'google/sdk_gphone_x86/generic_x86_arm:11/RSR1.201013.001/6903271:user/release-keys' +ABI: 'x86' +Build type: optimized +Zygote loaded classes=15747 post zygote classes=1481 +Dumping registered class loaders +#0 dalvik.system.PathClassLoader: [], parent #1 +#1 java.lang.BootClassLoader: [], no parent +#2 dalvik.system.PathClassLoader: [/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes15.dex:/data/data/backtraceio.backtraceio/code_cache/.overlay/base.apk/classes16.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes10.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes8.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes2.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes4.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes3.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes17.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes11.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes14.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes9.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes7.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes12.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes6.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes13.dex:/data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!classes5.dex], parent #1 +Done dumping class loaders +Classes initialized: 760 in 140.777ms +Intern table: 32164 strong; 519 weak +JNI: CheckJNI is on; globals=640 (plus 60 weak) +Libraries: /data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!/lib/x86/libbacktrace-native.so /data/app/~~lbMB2_9XcyZkiC41RLVuMg==/backtraceio.backtraceio-1XRWT_mS3AbJ9vbjbaCtkw==/base.apk!/lib/x86/libnative-lib.so libandroid.so libaudioeffect_jni.so libcompiler_rt.so libicu_jni.so libjavacore.so libjavacrypto.so libjnigraphics.so libmedia_jni.so libopenjdk.so librs_jni.so libsfplugin_ccodec.so libsoundpool.so libstats_jni.so libwebviewchromium_loader.so (16) +Heap: 74% free, 6892KB/25MB; 138095 objects +Dumping cumulative Gc timings +Average major GC reclaim bytes ratio inf over 0 GC cycles +Average major GC copied live bytes ratio 0.72272 over 4 major GCs +Cumulative bytes moved 11312128 +Cumulative objects moved 212604 +Peak regions allocated 27 (6912KB) / 768 (192MB) +Start Dumping histograms for 1 iterations for young concurrent copying +GrayAllDirtyImmuneObjects: Sum: 12.004ms 99% C.I. 12.004ms-12.004ms Avg: 12.004ms Max: 12.004ms +InitializePhase: Sum: 11.934ms 99% C.I. 11.934ms-11.934ms Avg: 11.934ms Max: 11.934ms +ProcessMarkStack: Sum: 9.977ms 99% C.I. 9.977ms-9.977ms Avg: 9.977ms Max: 9.977ms +ScanImmuneSpaces: Sum: 4.691ms 99% C.I. 4.691ms-4.691ms Avg: 4.691ms Max: 4.691ms +EnqueueFinalizerReferences: Sum: 4.233ms 99% C.I. 4.233ms-4.233ms Avg: 4.233ms Max: 4.233ms +ScanCardsForSpace: Sum: 3.451ms 99% C.I. 3.451ms-3.451ms Avg: 3.451ms Max: 3.451ms +VisitConcurrentRoots: Sum: 3.005ms 99% C.I. 3.005ms-3.005ms Avg: 3.005ms Max: 3.005ms +ResetStack: Sum: 1.680ms 99% C.I. 1.680ms-1.680ms Avg: 1.680ms Max: 1.680ms +SweepSystemWeaks: Sum: 1.050ms 99% C.I. 1.050ms-1.050ms Avg: 1.050ms Max: 1.050ms +ClearFromSpace: Sum: 423us 99% C.I. 423us-423us Avg: 423us Max: 423us +FlipOtherThreads: Sum: 333us 99% C.I. 333us-333us Avg: 333us Max: 333us +ForwardSoftReferences: Sum: 289us 99% C.I. 289us-289us Avg: 289us Max: 289us +SweepArray: Sum: 202us 99% C.I. 202us-202us Avg: 202us Max: 202us +EmptyRBMarkBitStack: Sum: 121us 99% C.I. 121us-121us Avg: 121us Max: 121us +VisitNonThreadRoots: Sum: 42us 99% C.I. 42us-42us Avg: 42us Max: 42us +FlipThreadRoots: Sum: 38us 99% C.I. 38us-38us Avg: 38us Max: 38us +ProcessReferences: Sum: 26us 99% C.I. 1us-25us Avg: 13us Max: 25us +(Paused)GrayAllNewlyDirtyImmuneObjects: Sum: 25us 99% C.I. 25us-25us Avg: 25us Max: 25us +(Paused)ClearCards: Sum: 19us 99% C.I. 0.250us-13us Avg: 1.461us Max: 13us +CopyingPhase: Sum: 17us 99% C.I. 17us-17us Avg: 17us Max: 17us +MarkZygoteLargeObjects: Sum: 16us 99% C.I. 16us-16us Avg: 16us Max: 16us +ThreadListFlip: Sum: 14us 99% C.I. 14us-14us Avg: 14us Max: 14us +SwapBitmaps: Sum: 9us 99% C.I. 9us-9us Avg: 9us Max: 9us +FreeList: Sum: 6us 99% C.I. 6us-6us Avg: 6us Max: 6us +ResumeRunnableThreads: Sum: 4us 99% C.I. 4us-4us Avg: 4us Max: 4us +(Paused)FlipCallback: Sum: 2us 99% C.I. 2us-2us Avg: 2us Max: 2us +(Paused)SetFromSpace: Sum: 1us 99% C.I. 1us-1us Avg: 1us Max: 1us +Done Dumping histograms +young concurrent copying paused: Sum: 63us 99% C.I. 63us-63us Avg: 63us Max: 63us +young concurrent copying freed-bytes: Avg: 1905KB Max: 1905KB Min: 1905KB +Freed-bytes histogram: 1600:1 +young concurrent copying total time: 53.653ms mean time: 53.653ms +young concurrent copying freed: 25923 objects with total size 1905KB +young concurrent copying throughput: 489113/s / 35MB/s per cpu-time: 47582634/s / 45MB/s +Average minor GC reclaim bytes ratio 0.92539 over 1 GC cycles +Average minor GC copied live bytes ratio 0.133529 over 2 minor GCs +Cumulative bytes moved 870264 +Cumulative objects moved 16602 +Peak regions allocated 27 (6912KB) / 768 (192MB) +Total time spent in GC: 53.653ms +Mean GC size throughput: 34MB/s per cpu-time: 44MB/s +Mean GC object throughput: 483160 objects/s +Total number of allocations 164018 +Total bytes allocated 8797KB +Total bytes freed 1905KB +Free memory 19MB +Free memory until GC 19MB +Free memory until OOME 185MB +Total memory 25MB +Max memory 192MB +Zygote space size 2980KB +Total mutator paused time: 63us +Total time waiting for GC to complete: 4.459us +Total GC count: 1 +Total GC time: 53.653ms +Total blocking GC count: 0 +Total blocking GC time: 0 +Native bytes total: 15962696 registered: 108600 +Total native bytes at last GC: 4019588 +/system/framework/oat/x86/android.hidl.manager-V1.0-java.odex: quicken +/system/framework/oat/x86/android.test.base.odex: quicken +/system/framework/oat/x86/android.hidl.base-V1.0-java.odex: quicken +Current JIT code cache size (used / resident): 52KB / 60KB +Current JIT data cache size (used / resident): 61KB / 96KB +Zygote JIT code cache size (at point of fork): 42KB / 44KB +Zygote JIT data cache size (at point of fork): 31KB / 36KB +Current JIT mini-debug-info size: 50KB +Current JIT capacity: 128KB +Current number of JIT JNI stub entries: 2 +Current number of JIT code cache entries: 207 +Total number of JIT compilations: 172 +Total number of JIT compilations for on stack replacement: 7 +Total number of JIT code cache collections: 1 +Memory used for stack maps: Avg: 85B Max: 660B Min: 12B +Memory used for compiled code: Avg: 324B Max: 2604B Min: 17B +Memory used for profiling info: Avg: 74B Max: 764B Min: 20B +Start Dumping histograms for 212 iterations for JIT timings +Compiling: Sum: 540.855ms 99% C.I. 0.030ms-21.292ms Avg: 2.563ms Max: 24.444ms +TrimMaps: Sum: 77.018ms 99% C.I. 1.850us-4245us Avg: 365.014us Max: 9813us +Code cache collection: Sum: 3.847ms 99% C.I. 3.847ms-3.847ms Avg: 3.847ms Max: 3.847ms +Done Dumping histograms +Memory used for compilation: Avg: 36KB Max: 322KB Min: 0B +ProfileSaver total_bytes_written=4974 +ProfileSaver total_number_of_writes=1 +ProfileSaver total_number_of_code_cache_queries=1 +ProfileSaver total_number_of_skipped_writes=0 +ProfileSaver total_number_of_failed_writes=0 +ProfileSaver total_ms_of_sleep=45000 +ProfileSaver total_ms_of_work=10 +ProfileSaver total_number_of_hot_spikes=4 +ProfileSaver total_number_of_wake_ups=3 + +suspend all histogram: Sum: 301us 99% C.I. 0.090us-104us Avg: 11.148us Max: 104us +DALVIK THREADS (20): +"Signal Catcher" daemon prio=10 tid=4 Runnable + | group="system" sCount=0 dsCount=0 flags=0 obj=0x13040b28 self=0xe738a810 + | sysTid=9216 nice=-20 cgrp=top-app sched=0/0 handle=0xdc5da1e0 + | state=R schedstat=( 17573906 201748 7 ) utm=0 stm=1 core=0 HZ=100 + | stack=0xdc4df000-0xdc4e1000 stackSize=1008KB + | held mutexes= "mutator lock"(shared held) + native: #00 pc 00542d9e /apex/com.android.art/lib/libart.so (art::DumpNativeStack(std::__1::basic_ostream >&, int, BacktraceMap*, char const*, art::ArtMethod*, void*, bool)+110) + native: #01 pc 006a0897 /apex/com.android.art/lib/libart.so (art::Thread::DumpStack(std::__1::basic_ostream >&, bool, BacktraceMap*, bool) const+1015) + native: #02 pc 0069a171 /apex/com.android.art/lib/libart.so (art::Thread::Dump(std::__1::basic_ostream >&, bool, BacktraceMap*, bool) const+65) + native: #03 pc 006c61b4 /apex/com.android.art/lib/libart.so (art::DumpCheckpoint::Run(art::Thread*)+1172) + native: #04 pc 006bf266 /apex/com.android.art/lib/libart.so (art::ThreadList::RunCheckpoint(art::Closure*, art::Closure*)+630) + native: #05 pc 006be1ce /apex/com.android.art/lib/libart.so (art::ThreadList::Dump(std::__1::basic_ostream >&, bool)+2446) + native: #06 pc 006bd70c /apex/com.android.art/lib/libart.so (art::ThreadList::DumpForSigQuit(std::__1::basic_ostream >&)+1644) + native: #07 pc 0064d654 /apex/com.android.art/lib/libart.so (art::Runtime::DumpForSigQuit(std::__1::basic_ostream >&)+212) + native: #08 pc 00665b6a /apex/com.android.art/lib/libart.so (art::SignalCatcher::HandleSigQuit()+1818) + native: #09 pc 0066496b /apex/com.android.art/lib/libart.so (art::SignalCatcher::Run(void*)+587) + native: #10 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #11 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + + +//expected api request to coroner api +// java stacktrace +(String declaringClass, String methodName, String fileName, int lineNumber) + + +// native stack +{ +"funcName": "syscall", +"library": "/apex/com.android.runtime/lib/bionic/libc.so", +"address": "0005ad68" +} + + + +"main" prio=5 tid=1 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x72287300 self=0xe7380e10 + | sysTid=9207 nice=-10 cgrp=top-app sched=0/0 handle=0xf583f478 + | state=S schedstat=( 44727379364 813394028 1292 ) utm=1691 stm=2781 core=1 HZ=100 + | stack=0xff020000-0xff022000 stackSize=8192KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 0005ad68 /apex/com.android.runtime/lib/bionic/libc.so (syscall+40) + native: #02 pc 001d82ec /apex/com.android.art/lib/libart.so (art::ConditionVariable::WaitHoldingLocks(art::Thread*)+108) + native: #03 pc 001d8273 /apex/com.android.art/lib/libart.so (art::ConditionVariable::Wait(art::Thread*)+35) + native: #04 pc 007894db /apex/com.android.art/lib/libart.so (art::GoToRunnable(art::Thread*)+507) + native: #05 pc 007892a1 /apex/com.android.art/lib/libart.so (art::JniMethodEnd(unsigned int, art::Thread*)+33) + native: #06 pc 02008de3 /memfd:jit-cache (deleted) (offset 2000000) (art_jni_trampoline+163) + native: #07 pc 02008cee /memfd:jit-cache (deleted) (offset 2000000) (backtraceio.backtraceio.MainActivity.handledException+46) + native: #08 pc 00142c04 /apex/com.android.art/lib/libart.so (art_quick_osr_stub+36) + native: #09 pc 003b138f /apex/com.android.art/lib/libart.so (art::jit::Jit::MaybeDoOnStackReplacement(art::Thread*, art::ArtMethod*, unsigned int, int, art::JValue*)+415) + native: #10 pc 007b2bf9 /apex/com.android.art/lib/libart.so (MterpMaybeDoOnStackReplacement+185) + native: #11 pc 0013a2f5 /apex/com.android.art/lib/libart.so (MterpHelpers+294) + native: #12 pc 00001034 /data/data/backtraceio.backtraceio/code_cache/.overlay/base.apk/classes16.dex (backtraceio.backtraceio.MainActivity.handledException+8) + native: #13 pc 0036fb02 /apex/com.android.art/lib/libart.so (art::interpreter::Execute(art::Thread*, art::CodeItemDataAccessor const&, art::ShadowFrame&, art::JValue, bool, bool) (.llvm.16375758241455872412)+370) + native: #14 pc 00379b00 /apex/com.android.art/lib/libart.so (art::interpreter::EnterInterpreterFromEntryPoint(art::Thread*, art::CodeItemDataAccessor const&, art::ShadowFrame*)+176) + native: #15 pc 0078b325 /apex/com.android.art/lib/libart.so (artQuickToInterpreterBridge+1061) + native: #16 pc 0014220d /apex/com.android.art/lib/libart.so (art_quick_to_interpreter_bridge+77) + native: #17 pc 0013b922 /apex/com.android.art/lib/libart.so (art_quick_invoke_stub+338) + native: #18 pc 001d0381 /apex/com.android.art/lib/libart.so (art::ArtMethod::Invoke(art::Thread*, unsigned int*, unsigned int, art::JValue*, char const*)+241) + native: #19 pc 00630008 /apex/com.android.art/lib/libart.so (art::InvokeMethod(art::ScopedObjectAccessAlreadyRunnable const&, _jobject*, _jobject*, _jobject*, unsigned int)+1464) + native: #20 pc 005886a0 /apex/com.android.art/lib/libart.so (art::Method_invoke(_JNIEnv*, _jobject*, _jobject*, _jobjectArray*)+80) + at java.lang.Thread.yield(Native method) + at backtraceio.backtraceio.MainActivity.handledException(MainActivity.java:157) + at java.lang.reflect.Method.invoke(Native method) + at androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:468) + at android.view.View.performClick(View.java:7448) + at android.view.View.performClickInternal(View.java:7425) + at android.view.View.access$3600(View.java:810) + at android.view.View$PerformClick.run(View.java:28305) + at android.os.Handler.handleCallback(Handler.java:938) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loop(Looper.java:223) + at android.app.ActivityThread.main(ActivityThread.java:7656) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947) + +"ADB-JDWP Connection Control Thread" daemon prio=0 tid=5 WaitingInMainDebuggerLoop + | group="system" sCount=1 dsCount=0 flags=1 obj=0x13040ba0 self=0xe738c410 + | sysTid=9218 nice=-20 cgrp=top-app sched=0/0 handle=0xdc3d81e0 + | state=S schedstat=( 4694215 13986760 9 ) utm=0 stm=0 core=1 HZ=100 + | stack=0xdc2dd000-0xdc2df000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b99 [vdso] (__kernel_vsyscall+9) + native: #01 pc 000cf496 /apex/com.android.runtime/lib/bionic/libc.so (__ppoll+38) + native: #02 pc 00083979 /apex/com.android.runtime/lib/bionic/libc.so (poll+105) + native: #03 pc 0000a493 /apex/com.android.art/lib/libadbconnection.so (adbconnection::AdbConnectionState::RunPollLoop(art::Thread*)+1171) + native: #04 pc 000086d2 /apex/com.android.art/lib/libadbconnection.so (adbconnection::CallbackFunction(void*)+1666) + native: #05 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #06 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"perfetto_hprof_listener" prio=10 tid=6 Native (still starting up) + | group="" sCount=1 dsCount=0 flags=1 obj=0x0 self=0xe7385410 + | sysTid=9217 nice=-20 cgrp=top-app sched=0/0 handle=0xdc4d91e0 + | state=S schedstat=( 6280850 9339243 10 ) utm=0 stm=0 core=0 HZ=100 + | stack=0xdc3de000-0xdc3e0000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 000ccf9c /apex/com.android.runtime/lib/bionic/libc.so (read+28) + native: #02 pc 0001aca2 /apex/com.android.art/lib/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::$_29> >(void*)+306) + native: #03 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #04 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"HeapTaskDaemon" daemon prio=5 tid=7 WaitingForTaskProcessor + | group="system" sCount=1 dsCount=0 flags=1 obj=0x130410c8 self=0xe7384610 + | sysTid=9220 nice=4 cgrp=top-app sched=0/0 handle=0xc74921e0 + | state=S schedstat=( 43450902 17904055 20 ) utm=1 stm=2 core=1 HZ=100 + | stack=0xc738f000-0xc7391000 stackSize=1040KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 0005ad68 /apex/com.android.runtime/lib/bionic/libc.so (syscall+40) + native: #02 pc 001d82ec /apex/com.android.art/lib/libart.so (art::ConditionVariable::WaitHoldingLocks(art::Thread*)+108) + native: #03 pc 001d8273 /apex/com.android.art/lib/libart.so (art::ConditionVariable::Wait(art::Thread*)+35) + native: #04 pc 0034a036 /apex/com.android.art/lib/libart.so (art::gc::TaskProcessor::GetTask(art::Thread*)+630) + native: #05 pc 0034aa64 /apex/com.android.art/lib/libart.so (art::gc::TaskProcessor::RunAllTasks(art::Thread*)+84) + native: #06 pc 005591f5 /apex/com.android.art/lib/libart.so (art::VMRuntime_runHeapTasks(_JNIEnv*, _jobject*)+53) + at dalvik.system.VMRuntime.runHeapTasks(Native method) + at java.lang.Daemons$HeapTaskDaemon.runInternal(Daemons.java:531) + at java.lang.Daemons$Daemon.run(Daemons.java:139) + at java.lang.Thread.run(Thread.java:923) + +"Binder:9207_1" prio=5 tid=8 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x13040c18 self=0xe7380010 + | sysTid=9224 nice=0 cgrp=top-app sched=0/0 handle=0xc6f701e0 + | state=S schedstat=( 1496174 7615537 6 ) utm=0 stm=0 core=1 HZ=100 + | stack=0xc6e75000-0xc6e77000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 000cd46c /apex/com.android.runtime/lib/bionic/libc.so (__ioctl+28) + native: #02 pc 00080e6a /apex/com.android.runtime/lib/bionic/libc.so (ioctl+58) + native: #03 pc 00050edb /system/lib/libbinder.so (android::IPCThreadState::talkWithDriver(bool)+331) + native: #04 pc 0005117a /system/lib/libbinder.so (android::IPCThreadState::getAndExecuteCommand()+42) + native: #05 pc 00051cb8 /system/lib/libbinder.so (android::IPCThreadState::joinThreadPool(bool)+72) + native: #06 pc 0007e309 /system/lib/libbinder.so (android::PoolThread::threadLoop()+41) + native: #07 pc 00015116 /system/lib/libutils.so (android::Thread::_threadLoop(void*)+374) + native: #08 pc 00098fee /system/lib/libandroid_runtime.so (android::AndroidRuntime::javaThreadShell(void*)+174) + native: #09 pc 000147d9 /system/lib/libutils.so (thread_data_t::trampoline(thread_data_t const*)+457) + native: #10 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #11 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"FinalizerWatchdogDaemon" daemon prio=5 tid=9 Waiting + | group="system" sCount=1 dsCount=0 flags=1 obj=0x13040c90 self=0xe7382a10 + | sysTid=9223 nice=4 cgrp=top-app sched=0/0 handle=0xc71771e0 + | state=S schedstat=( 398422 6565402 4 ) utm=0 stm=0 core=1 HZ=100 + | stack=0xc7074000-0xc7076000 stackSize=1040KB + | held mutexes= + at java.lang.Object.wait(Native method) + - waiting on <0x02d3c777> (a java.lang.Daemons$FinalizerWatchdogDaemon) + at java.lang.Object.wait(Object.java:442) + at java.lang.Object.wait(Object.java:568) + at java.lang.Daemons$FinalizerWatchdogDaemon.sleepUntilNeeded(Daemons.java:341) + - locked <0x02d3c777> (a java.lang.Daemons$FinalizerWatchdogDaemon) + at java.lang.Daemons$FinalizerWatchdogDaemon.runInternal(Daemons.java:321) + at java.lang.Daemons$Daemon.run(Daemons.java:139) + at java.lang.Thread.run(Thread.java:923) + +"ReferenceQueueDaemon" daemon prio=5 tid=10 Waiting + | group="system" sCount=1 dsCount=0 flags=1 obj=0x13040d08 self=0xe7388c10 + | sysTid=9221 nice=4 cgrp=top-app sched=0/0 handle=0xc73891e0 + | state=S schedstat=( 533566 3015911 3 ) utm=0 stm=0 core=1 HZ=100 + | stack=0xc7286000-0xc7288000 stackSize=1040KB + | held mutexes= + at java.lang.Object.wait(Native method) + - waiting on <0x07fc5fe4> (a java.lang.Class) + at java.lang.Object.wait(Object.java:442) + at java.lang.Object.wait(Object.java:568) + at java.lang.Daemons$ReferenceQueueDaemon.runInternal(Daemons.java:217) + - locked <0x07fc5fe4> (a java.lang.Class) + at java.lang.Daemons$Daemon.run(Daemons.java:139) + at java.lang.Thread.run(Thread.java:923) + +"Jit thread pool worker thread 0" daemon prio=5 tid=11 Native + | group="system" sCount=1 dsCount=0 flags=1 obj=0x13040d80 self=0xe7387e10 + | sysTid=9219 nice=0 cgrp=top-app sched=0/0 handle=0xc7596d60 + | state=S schedstat=( 468316720 500776964 211 ) utm=6 stm=39 core=0 HZ=100 + | stack=0xc7498000-0xc749a000 stackSize=1023KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 0005ad68 /apex/com.android.runtime/lib/bionic/libc.so (syscall+40) + native: #02 pc 001d82ec /apex/com.android.art/lib/libart.so (art::ConditionVariable::WaitHoldingLocks(art::Thread*)+108) + native: #03 pc 001d8273 /apex/com.android.art/lib/libart.so (art::ConditionVariable::Wait(art::Thread*)+35) + native: #04 pc 006c838f /apex/com.android.art/lib/libart.so (art::ThreadPool::GetTask(art::Thread*)+143) + native: #05 pc 006c73e5 /apex/com.android.art/lib/libart.so (art::ThreadPoolWorker::Run()+133) + native: #06 pc 006c6e9d /apex/com.android.art/lib/libart.so (art::ThreadPoolWorker::Callback(void*)+269) + native: #07 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #08 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"FinalizerDaemon" daemon prio=5 tid=12 Waiting + | group="system" sCount=1 dsCount=0 flags=1 obj=0x13040df8 self=0xe7386210 + | sysTid=9222 nice=4 cgrp=top-app sched=0/0 handle=0xc72801e0 + | state=S schedstat=( 1102483 7783564 5 ) utm=0 stm=0 core=1 HZ=100 + | stack=0xc717d000-0xc717f000 stackSize=1040KB + | held mutexes= + at java.lang.Object.wait(Native method) + - waiting on <0x0a047c4d> (a java.lang.Object) + at java.lang.Object.wait(Object.java:442) + at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:190) + - locked <0x0a047c4d> (a java.lang.Object) + at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:211) + at java.lang.Daemons$FinalizerDaemon.runInternal(Daemons.java:273) + at java.lang.Daemons$Daemon.run(Daemons.java:139) + at java.lang.Thread.run(Thread.java:923) + +"Binder:9207_2" prio=5 tid=13 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x13040e70 self=0xe7389a10 + | sysTid=9225 nice=0 cgrp=top-app sched=0/0 handle=0xc6e6f1e0 + | state=S schedstat=( 210561432 574003421 20 ) utm=10 stm=10 core=1 HZ=100 + | stack=0xc6d74000-0xc6d76000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 000cd46c /apex/com.android.runtime/lib/bionic/libc.so (__ioctl+28) + native: #02 pc 00080e6a /apex/com.android.runtime/lib/bionic/libc.so (ioctl+58) + native: #03 pc 00050edb /system/lib/libbinder.so (android::IPCThreadState::talkWithDriver(bool)+331) + native: #04 pc 0005117a /system/lib/libbinder.so (android::IPCThreadState::getAndExecuteCommand()+42) + native: #05 pc 00051cb8 /system/lib/libbinder.so (android::IPCThreadState::joinThreadPool(bool)+72) + native: #06 pc 0007e309 /system/lib/libbinder.so (android::PoolThread::threadLoop()+41) + native: #07 pc 00015116 /system/lib/libutils.so (android::Thread::_threadLoop(void*)+374) + native: #08 pc 00098fee /system/lib/libandroid_runtime.so (android::AndroidRuntime::javaThreadShell(void*)+174) + native: #09 pc 000147d9 /system/lib/libutils.so (thread_data_t::trampoline(thread_data_t const*)+457) + native: #10 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #11 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"Binder:9207_3" prio=5 tid=14 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x13040ee8 self=0xe7391810 + | sysTid=9226 nice=0 cgrp=top-app sched=0/0 handle=0xc6d6e1e0 + | state=S schedstat=( 17000557 50151632 14 ) utm=0 stm=1 core=1 HZ=100 + | stack=0xc6c73000-0xc6c75000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 000cd46c /apex/com.android.runtime/lib/bionic/libc.so (__ioctl+28) + native: #02 pc 00080e6a /apex/com.android.runtime/lib/bionic/libc.so (ioctl+58) + native: #03 pc 00050edb /system/lib/libbinder.so (android::IPCThreadState::talkWithDriver(bool)+331) + native: #04 pc 0005117a /system/lib/libbinder.so (android::IPCThreadState::getAndExecuteCommand()+42) + native: #05 pc 00051cb8 /system/lib/libbinder.so (android::IPCThreadState::joinThreadPool(bool)+72) + native: #06 pc 0007e309 /system/lib/libbinder.so (android::PoolThread::threadLoop()+41) + native: #07 pc 00015116 /system/lib/libutils.so (android::Thread::_threadLoop(void*)+374) + native: #08 pc 00098fee /system/lib/libandroid_runtime.so (android::AndroidRuntime::javaThreadShell(void*)+174) + native: #09 pc 000147d9 /system/lib/libutils.so (thread_data_t::trampoline(thread_data_t const*)+457) + native: #10 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #11 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"Binder:9207_4" prio=5 tid=15 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x13040f60 self=0xe7390a10 + | sysTid=9227 nice=0 cgrp=top-app sched=0/0 handle=0xc68411e0 + | state=S schedstat=( 8392907 55881465 12 ) utm=0 stm=0 core=0 HZ=100 + | stack=0xc6746000-0xc6748000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 000cd46c /apex/com.android.runtime/lib/bionic/libc.so (__ioctl+28) + native: #02 pc 00080e6a /apex/com.android.runtime/lib/bionic/libc.so (ioctl+58) + native: #03 pc 00050edb /system/lib/libbinder.so (android::IPCThreadState::talkWithDriver(bool)+331) + native: #04 pc 0005117a /system/lib/libbinder.so (android::IPCThreadState::getAndExecuteCommand()+42) + native: #05 pc 00051cb8 /system/lib/libbinder.so (android::IPCThreadState::joinThreadPool(bool)+72) + native: #06 pc 0007e309 /system/lib/libbinder.so (android::PoolThread::threadLoop()+41) + native: #07 pc 00015116 /system/lib/libutils.so (android::Thread::_threadLoop(void*)+374) + native: #08 pc 00098fee /system/lib/libandroid_runtime.so (android::AndroidRuntime::javaThreadShell(void*)+174) + native: #09 pc 000147d9 /system/lib/libutils.so (thread_data_t::trampoline(thread_data_t const*)+457) + native: #10 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #11 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"Profile Saver" daemon prio=5 tid=16 Native + | group="system" sCount=1 dsCount=0 flags=1 obj=0x13040fd8 self=0xe738ee10 + | sysTid=9228 nice=9 cgrp=top-app sched=0/0 handle=0xc67401e0 + | state=S schedstat=( 14918009 13675595 16 ) utm=1 stm=0 core=1 HZ=100 + | stack=0xc6645000-0xc6647000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b97 [vdso] (__kernel_vsyscall+7) + native: #01 pc 0005ad68 /apex/com.android.runtime/lib/bionic/libc.so (syscall+40) + native: #02 pc 001d82ec /apex/com.android.art/lib/libart.so (art::ConditionVariable::WaitHoldingLocks(art::Thread*)+108) + native: #03 pc 001d8273 /apex/com.android.art/lib/libart.so (art::ConditionVariable::Wait(art::Thread*)+35) + native: #04 pc 003cc829 /apex/com.android.art/lib/libart.so (art::ProfileSaver::Run()+633) + native: #05 pc 003d2e8f /apex/com.android.art/lib/libart.so (art::ProfileSaver::RunProfileSaverThread(void*)+175) + native: #06 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #07 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"RenderThread" daemon prio=7 tid=17 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x13041050 self=0xe7392610 + | sysTid=9229 nice=-10 cgrp=top-app sched=0/0 handle=0xc663f1e0 + | state=S schedstat=( 236695461 71606987 112 ) utm=4 stm=19 core=1 HZ=100 + | stack=0xc6544000-0xc6546000 stackSize=1008KB + | held mutexes= + native: #00 pc 00000b99 [vdso] (__kernel_vsyscall+9) + native: #01 pc 000cf2cb /apex/com.android.runtime/lib/bionic/libc.so (__epoll_pwait+43) + native: #02 pc 00088f9d /apex/com.android.runtime/lib/bionic/libc.so (epoll_wait+45) + native: #03 pc 0001a003 /system/lib/libutils.so (android::Looper::pollInner(int)+259) + native: #04 pc 00019e96 /system/lib/libutils.so (android::Looper::pollOnce(int, int*, int*, void**)+118) + native: #05 pc 002452c5 /system/lib/libhwui.so (android::uirenderer::ThreadBase::waitForWork()+149) + native: #06 pc 0026cab7 /system/lib/libhwui.so (android::uirenderer::renderthread::RenderThread::threadLoop()+119) + native: #07 pc 00015116 /system/lib/libutils.so (android::Thread::_threadLoop(void*)+374) + native: #08 pc 000147d9 /system/lib/libutils.so (thread_data_t::trampoline(thread_data_t const*)+457) + native: #09 pc 000e6974 /apex/com.android.runtime/lib/bionic/libc.so (__pthread_start(void*)+100) + native: #10 pc 00078567 /apex/com.android.runtime/lib/bionic/libc.so (__start_thread+71) + (no managed stack frames) + +"BacktraceHandlerThread" prio=5 tid=18 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x12cf4660 self=0xe738fc10 + | sysTid=9232 nice=0 cgrp=top-app sched=0/0 handle=0xc64281e0 + | state=S schedstat=( 313706628 570346891 185 ) utm=20 stm=11 core=0 HZ=100 + | stack=0xc6325000-0xc6327000 stackSize=1040KB + | held mutexes= + native: #00 pc 00000b99 [vdso] (__kernel_vsyscall+9) + native: #01 pc 000cf2cb /apex/com.android.runtime/lib/bionic/libc.so (__epoll_pwait+43) + native: #02 pc 00088f9d /apex/com.android.runtime/lib/bionic/libc.so (epoll_wait+45) + native: #03 pc 0001a003 /system/lib/libutils.so (android::Looper::pollInner(int)+259) + native: #04 pc 00019e96 /system/lib/libutils.so (android::Looper::pollOnce(int, int*, int*, void**)+118) + native: #05 pc 0010ef8b /system/lib/libandroid_runtime.so (android::android_os_MessageQueue_nativePollOnce(_JNIEnv*, _jobject*, long long, int)+59) + at android.os.MessageQueue.nativePollOnce(Native method) + at android.os.MessageQueue.next(MessageQueue.java:335) + at android.os.Looper.loop(Looper.java:183) + at android.os.HandlerThread.run(HandlerThread.java:67) + +"Timer-0" prio=5 tid=19 TimedWaiting + | group="main" sCount=1 dsCount=0 flags=1 obj=0x12cf5498 self=0xe738e010 + | sysTid=9233 nice=0 cgrp=top-app sched=0/0 handle=0xc62461e0 + | state=S schedstat=( 7524451 0 14 ) utm=0 stm=0 core=0 HZ=100 + | stack=0xc6143000-0xc6145000 stackSize=1040KB + | held mutexes= + at java.lang.Object.wait(Native method) + - waiting on <0x02569302> (a java.util.TaskQueue) + at java.lang.Object.wait(Object.java:442) + at java.util.TimerThread.mainLoop(Timer.java:559) + - locked <0x02569302> (a java.util.TaskQueue) + at java.util.TimerThread.run(Timer.java:512) + +"WifiManagerThread" prio=5 tid=20 Native + | group="main" sCount=1 dsCount=0 flags=1 obj=0x12cff1c0 self=0xe7399610 + | sysTid=9236 nice=0 cgrp=top-app sched=0/0 handle=0xc613d1e0 + | state=S schedstat=( 581694 0 1 ) utm=0 stm=0 core=0 HZ=100 + | stack=0xc603a000-0xc603c000 stackSize=1040KB + | held mutexes= + native: #00 pc 00000b99 [vdso] (__kernel_vsyscall+9) + native: #01 pc 000cf2cb /apex/com.android.runtime/lib/bionic/libc.so (__epoll_pwait+43) + native: #02 pc 00088f9d /apex/com.android.runtime/lib/bionic/libc.so (epoll_wait+45) + native: #03 pc 0001a003 /system/lib/libutils.so (android::Looper::pollInner(int)+259) + native: #04 pc 00019e96 /system/lib/libutils.so (android::Looper::pollOnce(int, int*, int*, void**)+118) + native: #05 pc 0010ef8b /system/lib/libandroid_runtime.so (android::android_os_MessageQueue_nativePollOnce(_JNIEnv*, _jobject*, long long, int)+59) + at android.os.MessageQueue.nativePollOnce(Native method) + at android.os.MessageQueue.next(MessageQueue.java:335) + at android.os.Looper.loop(Looper.java:183) + at android.os.HandlerThread.run(HandlerThread.java:67) + +"Thread-4" prio=5 tid=21 Sleeping + | group="main" sCount=1 dsCount=0 flags=1 obj=0x12d8a710 self=0xe7394210 + | sysTid=9237 nice=0 cgrp=top-app sched=0/0 handle=0xc60341e0 + | state=S schedstat=( 76232117 47640734 71 ) utm=5 stm=2 core=0 HZ=100 + | stack=0xc5f31000-0xc5f33000 stackSize=1040KB + | held mutexes= + at java.lang.Thread.sleep(Native method) + - sleeping on <0x05336413> (a java.lang.Object) + at java.lang.Thread.sleep(Thread.java:442) + - locked <0x05336413> (a java.lang.Object) + at java.lang.Thread.sleep(Thread.java:358) + at backtraceio.library.watchdog.BacktraceANRHandlerWatchdog.run(BacktraceANRHandlerWatchdog.java:118) + +"OkHttp ConnectionPool" daemon prio=5 tid=22 TimedWaiting + | group="main" sCount=1 dsCount=0 flags=1 obj=0x12d39c50 self=0xe739b210 + | sysTid=9239 nice=0 cgrp=top-app sched=0/0 handle=0xc56251e0 + | state=S schedstat=( 442059 1414330 1 ) utm=0 stm=0 core=0 HZ=100 + | stack=0xc5522000-0xc5524000 stackSize=1040KB + | held mutexes= + at java.lang.Object.wait(Native method) + - waiting on <0x09119650> (a com.android.okhttp.ConnectionPool) + at com.android.okhttp.ConnectionPool$1.run(ConnectionPool.java:106) + - locked <0x09119650> (a com.android.okhttp.ConnectionPool) + at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641) + at java.lang.Thread.run(Thread.java:923) + +----- end 9207 ----- + +----- Waiting Channels: pid 9207 at 2025-03-27 21:02:38 ----- +Cmd line: backtraceio.backtraceio + +sysTid=9207 0 +sysTid=9216 do_sigtimedwait +sysTid=9217 pipe_read +sysTid=9218 do_sys_poll +sysTid=9219 futex_wait_queue_me +sysTid=9220 futex_wait_queue_me +sysTid=9221 futex_wait_queue_me +sysTid=9222 futex_wait_queue_me +sysTid=9223 futex_wait_queue_me +sysTid=9224 binder_thread_read +sysTid=9225 binder_thread_read +sysTid=9226 binder_thread_read +sysTid=9227 binder_thread_read +sysTid=9228 futex_wait_queue_me +sysTid=9229 do_epoll_wait +sysTid=9232 do_epoll_wait +sysTid=9233 futex_wait_queue_me +sysTid=9236 do_epoll_wait +sysTid=9237 futex_wait_queue_me +sysTid=9239 futex_wait_queue_me + +----- end 9207 ----- diff --git a/packages/react-native/tests/anrReporterTests.spec.ts b/packages/react-native/tests/anrReporterTests.spec.ts new file mode 100644 index 00000000..122d1787 --- /dev/null +++ b/packages/react-native/tests/anrReporterTests.spec.ts @@ -0,0 +1,128 @@ +import { NativeModules, Platform } from 'react-native'; + +// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws. +jest.mock('react-native', () => ({ + NativeModules: {}, + Platform: { + OS: 'android', + select: (options: Record) => + options.android !== undefined ? options.android : options.default, + }, +})); + +const getAnrExitInfo = jest.fn(); +NativeModules.BacktraceReactNative = { getAnrExitInfo }; +(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { AnrReporter } = require('../src/anr/AnrReporter'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const MARKER_PATH = '/data/backtrace-anr-marker'; + +function createFileSystem(files: Record = {}) { + const storage = new Map(Object.entries(files)); + return { + storage, + applicationDirectory: () => '/data', + exists: async (path: string) => storage.has(path), + readFile: async (path: string) => storage.get(path) as string, + writeFile: async (path: string, content: string) => { + storage.set(path, content); + }, + }; +} + +function createClient(send = jest.fn().mockResolvedValue({ status: 'Ok' })) { + return { send } as never; +} + +function record(timestamp: number, frames?: object[]) { + return { + timestamp, + message: `anr at ${timestamp}`, + attributes: { PID: 123 }, + stackTrace: 'raw dump', + mainThreadFrames: frames, + }; +} + +const FRAMES = [{ funcName: 'android.os.MessageQueue.next', library: 'MessageQueue.java', line: 335 }]; + +describe('AnrReporter', () => { + beforeEach(() => { + getAnrExitInfo.mockReset(); + Platform.OS = 'android'; + }); + + it('does not create on platforms other than android', () => { + Platform.OS = 'ios'; + expect(AnrReporter.create(createFileSystem())).toBeUndefined(); + }); + + it('does not create without the native module method', () => { + const saved = NativeModules.BacktraceReactNative; + NativeModules.BacktraceReactNative = {}; + expect(AnrReporter.create(createFileSystem())).toBeUndefined(); + NativeModules.BacktraceReactNative = saved; + }); + + it('passes the stored marker to the native reader', async () => { + const fileSystem = createFileSystem({ [MARKER_PATH]: '1234' }); + getAnrExitInfo.mockResolvedValue([]); + + await AnrReporter.create(fileSystem)?.report(createClient()); + + expect(getAnrExitInfo).toHaveBeenCalledWith(1234); + }); + + it('sends records oldest first and advances the marker after each accepted send', async () => { + const fileSystem = createFileSystem(); + getAnrExitInfo.mockResolvedValue([record(1, FRAMES), record(2, FRAMES)]); + const send = jest.fn().mockResolvedValue({ status: 'Ok' }); + + await AnrReporter.create(fileSystem)?.report(createClient(send)); + + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[0][0].attributes['error.type']).toBe('Hang'); + expect(fileSystem.storage.get(MARKER_PATH)).toBe('2'); + }); + + it('stops on a failed send and keeps the marker at the last accepted record', async () => { + const fileSystem = createFileSystem(); + getAnrExitInfo.mockResolvedValue([record(1, FRAMES), record(2, FRAMES), record(3, FRAMES)]); + const send = jest + .fn() + .mockResolvedValueOnce({ status: 'Ok' }) + .mockResolvedValueOnce({ status: 'Network Error' }); + + await AnrReporter.create(fileSystem)?.report(createClient(send)); + + expect(send).toHaveBeenCalledTimes(2); + expect(fileSystem.storage.get(MARKER_PATH)).toBe('1'); + }); + + it('skips a record without frames but still advances the marker', async () => { + const fileSystem = createFileSystem(); + getAnrExitInfo.mockResolvedValue([record(7, undefined)]); + const send = jest.fn(); + + await AnrReporter.create(fileSystem)?.report(createClient(send)); + + expect(send).not.toHaveBeenCalled(); + expect(fileSystem.storage.get(MARKER_PATH)).toBe('7'); + }); + + it('attaches the parsed frames as the main thread and the raw dump as an attribute', async () => { + const fileSystem = createFileSystem(); + getAnrExitInfo.mockResolvedValue([record(1, FRAMES)]); + const send = jest.fn().mockResolvedValue({ status: 'Ok' }); + + await AnrReporter.create(fileSystem)?.report(createClient(send)); + + const report = send.mock.calls[0][0]; + expect(report.stackTrace['main']).toEqual(FRAMES); + expect(report.attributes['ANR stacktrace']).toBe('raw dump'); + expect(report.attributes['PID']).toBe(123); + }); +}); From 1bc051f39d26ed06dc226320cd49b5108b7f37f9 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 25 Aug 2026 21:06:55 +0200 Subject: [PATCH 05/13] react-native: pause ANR watchdog in background, stop on reload, exit-info timestamps and dump attachment, rename debug option --- packages/react-native/README.md | 4 +- .../library/BacktraceAnrWatchdog.java | 6 + .../backtrace/library/anr/AnrWatchdog.java | 1 + .../anr/ExitInfoStackTraceParserTest.java | 3 + packages/react-native/src/BacktraceClient.ts | 2 +- .../src/BacktraceConfiguration.ts | 2 +- packages/react-native/src/anr/AnrReporter.ts | 13 +- .../src/anr/AnrWatchdogHandler.ts | 25 +++- .../tests/anrReporterTests.spec.ts | 10 +- .../tests/anrWatchdogHandlerTests.spec.ts | 116 ++++++++++++++++++ 10 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 packages/react-native/tests/anrWatchdogHandlerTests.spec.ts diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 1ab69f85..8bbfa589 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -302,7 +302,7 @@ Two detection mechanisms are available: - `threshold` monitors the main thread from a separate thread. If the main thread remains unresponsive for longer than the configured timeout (default: 5 seconds), an ANR is reported. It works on every Android version and reports while the application is still hung, so a report can be lost when the system kills the process before - the upload finishes. + the upload finishes. Detection pauses while the application is in the background. - `applicationExit` retrieves the ANRs the system recorded for previous runs of the process from `ApplicationExitInfo` and reports them on the next application start, including the thread dump the system captured when it declared the ANR. It requires API 30 or above and survives the process kill. Reported records @@ -323,7 +323,7 @@ const options: BacktraceConfiguration = { | `enable` | Boolean | Determines if ANR detection is enabled. | `false` |
  • - [ ]
| | `type` | `'threshold'` \| `'applicationExit'` | Detection mechanism. | `threshold` |
  • - [ ]
| | `timeout` | Number | Time in milliseconds the main thread stays blocked before an ANR is reported. Applies to the `threshold` type only. | `5000` |
  • - [ ]
| -| `debug` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `threshold` type only. | `false` |
  • - [ ]
| +| `disableWhenDebuggerAttached` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `threshold` type only. | `false` |
  • - [ ]
| --- diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java index 12845058..a0a5d5b3 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java @@ -51,6 +51,12 @@ public void stop() { this.watchdog = null; } + @Override + public void invalidate() { + stop(); + super.invalidate(); + } + @ReactMethod() public void addListener(String eventName) {} diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java index a5426b71..45252316 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java @@ -64,5 +64,6 @@ public void run() { public void stopMonitoring() { this.shouldStop = true; + this.interrupt(); } } diff --git a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java index 250ac271..d9d8fafe 100644 --- a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java +++ b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java @@ -13,6 +13,9 @@ import java.util.Map; import org.junit.Test; +// The report stack comes entirely from parsing the text dump the system writes for an ANR, so +// a parsing mistake ships reports with a wrong or empty stack. The cases run against a real +// dump captured from a device and cover both the Java and the native frame formats. public class ExitInfoStackTraceParserTest { private static final String ANR_APPEXIT_STACKTRACE_FILE = "anrAppExitInfoStacktrace.txt"; diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index d7600d20..08f305e3 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -155,7 +155,7 @@ export class BacktraceClient extends BacktraceCoreClient } this._anrWatchdogHandler = AnrWatchdogHandler.create(); - this._anrWatchdogHandler?.start(this, anr.timeout ?? 0, anr.debug ?? false); + this._anrWatchdogHandler?.start(this, anr.timeout ?? 0, anr.disableWhenDebuggerAttached ?? false); } private initializeNativeCrashReporter(): CrashReporter | undefined { diff --git a/packages/react-native/src/BacktraceConfiguration.ts b/packages/react-native/src/BacktraceConfiguration.ts index 052b4755..1a4ef17e 100644 --- a/packages/react-native/src/BacktraceConfiguration.ts +++ b/packages/react-native/src/BacktraceConfiguration.ts @@ -27,7 +27,7 @@ export interface BacktraceAnrConfiguration { * When true, detection is disabled while a debugger is attached. * Applies to the `threshold` type only. By default the value is set to false. */ - debug?: boolean; + disableWhenDebuggerAttached?: boolean; } export interface BacktraceConfiguration extends SdkConfiguration { diff --git a/packages/react-native/src/anr/AnrReporter.ts b/packages/react-native/src/anr/AnrReporter.ts index a85e060c..4fec72cc 100644 --- a/packages/react-native/src/anr/AnrReporter.ts +++ b/packages/react-native/src/anr/AnrReporter.ts @@ -1,4 +1,4 @@ -import { BacktraceReport, type BacktraceStackFrame } from '@backtrace/sdk-core'; +import { BacktraceReport, BacktraceStringAttachment, type BacktraceStackFrame } from '@backtrace/sdk-core'; import { NativeModules, Platform } from 'react-native'; import type { BacktraceClient } from '../BacktraceClient'; import { DebuggerHelper } from '../common/DebuggerHelper'; @@ -63,20 +63,21 @@ export class AnrReporter { await this.writeMarker(record.timestamp); } - } catch { - return; + } catch (err) { + console.warn('Backtrace: cannot report ANRs from application exit info.', err); } } private buildReport(record: AnrExitInfoRecord): BacktraceReport { + // an exception stack is copied into the error annotation, so the dump goes only to the attachment const report = new BacktraceReport( - new AnrException(record.message, record.stackTrace ?? ''), + new AnrException(record.message, ''), { ...record.attributes, 'error.type': 'Hang', - 'ANR stacktrace': record.stackTrace ?? '', }, - [], + record.stackTrace ? [new BacktraceStringAttachment('anr-stacktrace.txt', record.stackTrace)] : [], + { timestamp: record.timestamp }, ); report.addStackTrace('main', record.mainThreadFrames as BacktraceStackFrame[]); diff --git a/packages/react-native/src/anr/AnrWatchdogHandler.ts b/packages/react-native/src/anr/AnrWatchdogHandler.ts index 9c6bd8fd..7e4e3dc7 100644 --- a/packages/react-native/src/anr/AnrWatchdogHandler.ts +++ b/packages/react-native/src/anr/AnrWatchdogHandler.ts @@ -1,5 +1,12 @@ import { BacktraceReport, type BacktraceStackFrame } from '@backtrace/sdk-core'; -import { NativeEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native'; +import { + AppState, + NativeEventEmitter, + NativeModules, + Platform, + type EmitterSubscription, + type NativeEventSubscription, +} from 'react-native'; import type { BacktraceClient } from '../BacktraceClient'; import { DebuggerHelper } from '../common/DebuggerHelper'; import { AnrException } from './AnrException'; @@ -13,6 +20,7 @@ interface AnrDetectedPayload { export class AnrWatchdogHandler { private _subscription?: EmitterSubscription; + private _appStateSubscription?: NativeEventSubscription; private constructor(private readonly _watchdog: NonNullable) {} @@ -33,7 +41,7 @@ export class AnrWatchdogHandler { return new AnrWatchdogHandler(watchdog); } - public start(client: BacktraceClient, timeout: number, debug: boolean): void { + public start(client: BacktraceClient, timeout: number, disableWhenDebuggerAttached: boolean): void { this._subscription = new NativeEventEmitter(this._watchdog).addListener( AnrDetectedEvent, (payload: AnrDetectedPayload) => { @@ -49,12 +57,23 @@ export class AnrWatchdogHandler { }, ); - this._watchdog.start(timeout, debug); + // Android freezes backgrounded apps, and the watchdog would misread the resume as a hang + this._appStateSubscription = AppState.addEventListener('change', (state) => { + if (state === 'background') { + this._watchdog.stop(); + } else if (state === 'active') { + this._watchdog.start(timeout, disableWhenDebuggerAttached); + } + }); + + this._watchdog.start(timeout, disableWhenDebuggerAttached); } public dispose(): void { this._subscription?.remove(); this._subscription = undefined; + this._appStateSubscription?.remove(); + this._appStateSubscription = undefined; this._watchdog.stop?.(); } } diff --git a/packages/react-native/tests/anrReporterTests.spec.ts b/packages/react-native/tests/anrReporterTests.spec.ts index 122d1787..b58ee0af 100644 --- a/packages/react-native/tests/anrReporterTests.spec.ts +++ b/packages/react-native/tests/anrReporterTests.spec.ts @@ -1,3 +1,6 @@ +// The reporter sends each ANR the system recorded exactly once: it saves the timestamp of the +// last accepted report and resumes from it on the next launch. Faked native records and send +// results walk the retry and loss orderings a device cannot produce on demand. import { NativeModules, Platform } from 'react-native'; // This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws. @@ -85,6 +88,8 @@ describe('AnrReporter', () => { expect(send).toHaveBeenCalledTimes(2); expect(send.mock.calls[0][0].attributes['error.type']).toBe('Hang'); + expect(send.mock.calls[0][0].timestamp).toBe(1); + expect(send.mock.calls[1][0].timestamp).toBe(2); expect(fileSystem.storage.get(MARKER_PATH)).toBe('2'); }); @@ -113,7 +118,7 @@ describe('AnrReporter', () => { expect(fileSystem.storage.get(MARKER_PATH)).toBe('7'); }); - it('attaches the parsed frames as the main thread and the raw dump as an attribute', async () => { + it('attaches the parsed frames as the main thread and the raw dump as an attachment', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(1, FRAMES)]); const send = jest.fn().mockResolvedValue({ status: 'Ok' }); @@ -122,7 +127,8 @@ describe('AnrReporter', () => { const report = send.mock.calls[0][0]; expect(report.stackTrace['main']).toEqual(FRAMES); - expect(report.attributes['ANR stacktrace']).toBe('raw dump'); + expect(report.attachments).toHaveLength(1); + expect(report.attachments[0].name).toBe('anr-stacktrace.txt'); expect(report.attributes['PID']).toBe(123); }); }); diff --git a/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts b/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts new file mode 100644 index 00000000..abe050c1 --- /dev/null +++ b/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts @@ -0,0 +1,116 @@ +// A hang report only exists if the handler hears the native event and sends it; a broken +// subscription loses every report with no error anywhere. Mocked native modules and faked +// app-state changes cover the event flow and the background pause. +import { AppState, NativeModules, Platform } from 'react-native'; + +// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws. +jest.mock('react-native', () => { + const appStateListeners: Array<(state: string) => void> = []; + let emitterListener: ((payload: unknown) => void) | undefined; + + class NativeEventEmitter { + public addListener(_: string, callback: (payload: unknown) => void) { + emitterListener = callback; + return { remove: jest.fn() }; + } + + public static emit(payload: unknown) { + emitterListener?.(payload); + } + } + + return { + NativeModules: {}, + Platform: { + OS: 'android', + select: (options: Record) => + options.android !== undefined ? options.android : options.default, + }, + NativeEventEmitter, + AppState: { + __listeners: appStateListeners, + addEventListener: (_: string, callback: (state: string) => void) => { + appStateListeners.push(callback); + return { remove: jest.fn() }; + }, + }, + }; +}); + +const watchdogMock = { + start: jest.fn(), + stop: jest.fn(), +}; +NativeModules.BacktraceAnrWatchdog = watchdogMock; +(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { AnrWatchdogHandler } = require('../src/anr/AnrWatchdogHandler'); +const { NativeEventEmitter: EmitterMock } = require('react-native'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const PAYLOAD = { + stackTrace: 'android.os.MessageQueue.next(MessageQueue.java:335)\n', + frames: [{ funcName: 'android.os.MessageQueue.next', library: 'MessageQueue.java', line: 335 }], +}; + +function createClient() { + return { + send: jest.fn().mockResolvedValue({ status: 'Ok' }), + breadcrumbs: { info: jest.fn() }, + }; +} + +function appStateListeners(): Array<(state: string) => void> { + return (AppState as unknown as { __listeners: Array<(state: string) => void> }).__listeners; +} + +describe('AnrWatchdogHandler', () => { + beforeEach(() => { + watchdogMock.start.mockClear(); + watchdogMock.stop.mockClear(); + appStateListeners().length = 0; + Platform.OS = 'android'; + }); + + it('does not create on platforms other than android', () => { + Platform.OS = 'ios'; + expect(AnrWatchdogHandler.create()).toBeUndefined(); + }); + + it('starts the native watchdog with the configured values', () => { + AnrWatchdogHandler.create()?.start(createClient() as never, 3000, true); + expect(watchdogMock.start).toHaveBeenCalledWith(3000, true); + }); + + it('sends a Hang report with the emitted frames and adds a breadcrumb', () => { + const client = createClient(); + AnrWatchdogHandler.create()?.start(client as never, 0, false); + + EmitterMock.emit(PAYLOAD); + + expect(client.breadcrumbs.info).toHaveBeenCalledWith('ANR detected - thread is blocked'); + const report = client.send.mock.calls[0][0]; + expect(report.attributes['error.type']).toBe('Hang'); + expect(report.stackTrace['main']).toEqual(PAYLOAD.frames); + }); + + it('pauses detection in the background and resumes it in the foreground', () => { + AnrWatchdogHandler.create()?.start(createClient() as never, 3000, true); + const [onAppStateChange] = appStateListeners(); + + onAppStateChange('background'); + expect(watchdogMock.stop).toHaveBeenCalledTimes(1); + + onAppStateChange('active'); + expect(watchdogMock.start).toHaveBeenLastCalledWith(3000, true); + expect(watchdogMock.start).toHaveBeenCalledTimes(2); + }); + + it('stops the native watchdog on dispose', () => { + const handler = AnrWatchdogHandler.create(); + handler?.start(createClient() as never, 0, false); + handler?.dispose(); + expect(watchdogMock.stop).toHaveBeenCalled(); + }); +}); From 5bf78982070d8e6518e9e7b4952b5a56b222366f Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 26 Aug 2026 14:21:33 +0200 Subject: [PATCH 06/13] react-native: update BacktraceAnrType, update README --- packages/react-native/README.md | 15 ++++++++------- packages/react-native/src/BacktraceClient.ts | 4 ++-- .../react-native/src/BacktraceConfiguration.ts | 15 +++++++++------ packages/react-native/src/index.ts | 6 +++++- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 8bbfa589..3bceb2e6 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -299,21 +299,22 @@ The Backtrace react-native SDK can detect Application Not Responding errors on A Two detection mechanisms are available: -- `threshold` monitors the main thread from a separate thread. If the main thread remains unresponsive for longer - than the configured timeout (default: 5 seconds), an ANR is reported. It works on every Android version and - reports while the application is still hung, so a report can be lost when the system kills the process before - the upload finishes. Detection pauses while the application is in the background. -- `applicationExit` retrieves the ANRs the system recorded for previous runs of the process from +- `BacktraceAnrType.Threshold` monitors the main thread from a separate thread. If the main thread remains + unresponsive for longer than the configured timeout (default: 5 seconds), an ANR is reported immediately, while + the application is still hung. It works on all supported Android versions. +- `BacktraceAnrType.ApplicationExit` retrieves the ANRs the system recorded for previous runs of the process from `ApplicationExitInfo` and reports them on the next application start, including the thread dump the system captured when it declared the ANR. It requires API 30 or above and survives the process kill. Reported records are remembered, so each ANR is reported once. ```ts +import { BacktraceAnrType, BacktraceConfiguration } from '@backtrace/react-native'; + const options: BacktraceConfiguration = { url: SUBMISSION_URL, anr: { enable: true, - type: 'threshold', + type: BacktraceAnrType.Threshold, }, }; ``` @@ -321,7 +322,7 @@ const options: BacktraceConfiguration = { | Option Name | Type | Description | Default | Required? | | ----------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------ | | `enable` | Boolean | Determines if ANR detection is enabled. | `false` |
  • - [ ]
| -| `type` | `'threshold'` \| `'applicationExit'` | Detection mechanism. | `threshold` |
  • - [ ]
| +| `type` | `BacktraceAnrType` | Detection mechanism: `Threshold` or `ApplicationExit`. | `Threshold` |
  • - [ ]
| | `timeout` | Number | Time in milliseconds the main thread stays blocked before an ANR is reported. Applies to the `threshold` type only. | `5000` |
  • - [ ]
| | `disableWhenDebuggerAttached` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `threshold` type only. | `false` |
  • - [ ]
| diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index 08f305e3..b3711609 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -11,7 +11,7 @@ import { import { NativeModules, Platform } from 'react-native'; import { AnrReporter } from './anr/AnrReporter'; import { AnrWatchdogHandler } from './anr/AnrWatchdogHandler'; -import { type BacktraceConfiguration } from './BacktraceConfiguration'; +import { BacktraceAnrType, type BacktraceConfiguration } from './BacktraceConfiguration'; import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage'; import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; import type { BacktraceClientSetup } from './builder/BacktraceClientSetup'; @@ -144,7 +144,7 @@ export class BacktraceClient extends BacktraceCoreClient return; } - if (anr.type === 'applicationExit') { + if (anr.type === BacktraceAnrType.ApplicationExit) { const fileSystem = this.fileSystem as FileSystem | undefined; if (!fileSystem) { return; diff --git a/packages/react-native/src/BacktraceConfiguration.ts b/packages/react-native/src/BacktraceConfiguration.ts index 1a4ef17e..56b092ec 100644 --- a/packages/react-native/src/BacktraceConfiguration.ts +++ b/packages/react-native/src/BacktraceConfiguration.ts @@ -1,6 +1,9 @@ import { type BacktraceConfiguration as SdkConfiguration } from '@backtrace/sdk-core'; -export type BacktraceAnrType = 'threshold' | 'applicationExit'; +export enum BacktraceAnrType { + Threshold = 'threshold', + ApplicationExit = 'applicationExit', +} export interface BacktraceAnrConfiguration { /** @@ -10,22 +13,22 @@ export interface BacktraceAnrConfiguration { enable?: boolean; /** - * Detection mechanism. `threshold` watches the main thread and reports as soon as it is - * blocked, on any Android version. `applicationExit` reports ANRs the system recorded, + * Detection mechanism. `Threshold` watches the main thread and reports as soon as it is + * blocked, on all supported Android versions. `ApplicationExit` reports ANRs the system recorded, * on the next application start, and requires API 30 or above. - * By default the value is set to `threshold`. + * By default the value is set to `Threshold`. */ type?: BacktraceAnrType; /** * Time in milliseconds the main thread must be blocked before an ANR is reported. - * Applies to the `threshold` type only. By default the value is set to 5000. + * Applies to the `Threshold` type only. By default the value is set to 5000. */ timeout?: number; /** * When true, detection is disabled while a debugger is attached. - * Applies to the `threshold` type only. By default the value is set to false. + * Applies to the `Threshold` type only. By default the value is set to false. */ disableWhenDebuggerAttached?: boolean; } diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 1a187999..b19a5ab1 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -13,7 +13,11 @@ export { } from '@backtrace/sdk-core'; export * from './attachment/'; export { BacktraceClient } from './BacktraceClient'; -export { type BacktraceConfiguration } from './BacktraceConfiguration'; +export { + BacktraceAnrType, + type BacktraceAnrConfiguration, + type BacktraceConfiguration, +} from './BacktraceConfiguration'; export { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; export { ErrorBoundary } from './ErrorBoundary'; export * from './storage/'; From 21d40990320d0522a480ce6402c2efb43b18c43d Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 26 Aug 2026 15:27:33 +0200 Subject: [PATCH 07/13] react-native: capture all threads in threshold reports --- packages/react-native/README.md | 3 ++- .../library/BacktraceAnrWatchdog.java | 26 ++++++++++++++++++- .../backtrace/library/anr/AnrWatchdog.java | 7 +++-- .../src/anr/AnrWatchdogHandler.ts | 20 ++++++++++++++ .../tests/anrWatchdogHandlerTests.spec.ts | 18 +++++++++++++ 5 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 3bceb2e6..e71f2af0 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -301,7 +301,8 @@ Two detection mechanisms are available: - `BacktraceAnrType.Threshold` monitors the main thread from a separate thread. If the main thread remains unresponsive for longer than the configured timeout (default: 5 seconds), an ANR is reported immediately, while - the application is still hung. It works on all supported Android versions. + the application is still hung. The report carries the stack traces of every Java thread, with the blocked main + thread as the faulting one. It works on all supported Android versions. - `BacktraceAnrType.ApplicationExit` retrieves the ANRs the system recorded for previous runs of the process from `ApplicationExitInfo` and reports them on the next application start, including the thread dump the system captured when it declared the ANR. It requires API 30 or above and survives the process kill. Reported records diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java index a0a5d5b3..2aaf0e4d 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAnrWatchdog.java @@ -1,14 +1,20 @@ package backtraceio.library; +import android.os.Looper; + import androidx.annotation.NonNull; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.module.annotations.ReactModule; +import java.util.Map; + import backtraceio.library.anr.AnrWatchdog; import backtraceio.library.anr.StackFrameMapper; @@ -63,15 +69,33 @@ public void addListener(String eventName) {} @ReactMethod() public void removeListeners(Integer count) {} - private void emitAnrDetected(StackTraceElement[] mainThreadFrames) { + private void emitAnrDetected(Map allThreads) { ReactApplicationContext context = getReactApplicationContext(); if (!context.hasActiveReactInstance()) { return; } + Thread mainThread = Looper.getMainLooper().getThread(); + StackTraceElement[] mainThreadFrames = allThreads.get(mainThread); + if (mainThreadFrames == null) { + mainThreadFrames = mainThread.getStackTrace(); + } + + WritableArray threads = new WritableNativeArray(); + for (Map.Entry entry : allThreads.entrySet()) { + if (entry.getKey() == mainThread) { + continue; + } + WritableMap thread = new WritableNativeMap(); + thread.putString("name", entry.getKey().getName()); + thread.putArray("frames", StackFrameMapper.toWritableFrames(entry.getValue())); + threads.pushMap(thread); + } + WritableMap event = new WritableNativeMap(); event.putString("stackTrace", StackFrameMapper.toFormattedString(mainThreadFrames)); event.putArray("frames", StackFrameMapper.toWritableFrames(mainThreadFrames)); + event.putArray("threads", threads); // getJSModule(RCTDeviceEventEmitter) drops events silently in bridgeless mode context.emitDeviceEvent(ANR_DETECTED_EVENT, event); diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java index 45252316..b0275f4a 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/AnrWatchdog.java @@ -5,9 +5,11 @@ import android.os.Looper; import android.util.Log; +import java.util.Map; + public class AnrWatchdog extends Thread { public interface OnAnrDetected { - void onAnrDetected(StackTraceElement[] mainThreadFrames); + void onAnrDetected(Map threads); } public static final int DEFAULT_ANR_TIMEOUT = 5000; @@ -22,6 +24,7 @@ public interface OnAnrDetected { private volatile boolean shouldStop = false; public AnrWatchdog(int timeout, boolean debug, OnAnrDetected listener) { + super("BacktraceAnrWatchdog"); this.timeout = timeout; this.debug = debug; this.listener = listener; @@ -58,7 +61,7 @@ public void run() { } reported = true; - this.listener.onAnrDetected(Looper.getMainLooper().getThread().getStackTrace()); + this.listener.onAnrDetected(Thread.getAllStackTraces()); } } diff --git a/packages/react-native/src/anr/AnrWatchdogHandler.ts b/packages/react-native/src/anr/AnrWatchdogHandler.ts index 7e4e3dc7..f0c9db72 100644 --- a/packages/react-native/src/anr/AnrWatchdogHandler.ts +++ b/packages/react-native/src/anr/AnrWatchdogHandler.ts @@ -13,9 +13,15 @@ import { AnrException } from './AnrException'; const AnrDetectedEvent = 'BacktraceAnrDetected'; +interface AnrThreadPayload { + name: string; + frames: BacktraceStackFrame[]; +} + interface AnrDetectedPayload { stackTrace: string; frames: BacktraceStackFrame[]; + threads?: AnrThreadPayload[]; } export class AnrWatchdogHandler { @@ -53,6 +59,7 @@ export class AnrWatchdogHandler { [], ); report.addStackTrace('main', payload.frames); + this.addOtherThreads(report, payload.threads); client.send(report); }, ); @@ -69,6 +76,19 @@ export class AnrWatchdogHandler { this._watchdog.start(timeout, disableWhenDebuggerAttached); } + private addOtherThreads(report: BacktraceReport, threads?: AnrThreadPayload[]): void { + const usedNames = new Set(['main']); + for (const thread of threads ?? []) { + const base = thread.name || 'unknown'; + let name = base; + for (let ordinal = 2; usedNames.has(name); ordinal++) { + name = `${base}-${ordinal}`; + } + usedNames.add(name); + report.addStackTrace(name, thread.frames); + } + } + public dispose(): void { this._subscription?.remove(); this._subscription = undefined; diff --git a/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts b/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts index abe050c1..c7abc9e8 100644 --- a/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts +++ b/packages/react-native/tests/anrWatchdogHandlerTests.spec.ts @@ -52,6 +52,11 @@ const { NativeEventEmitter: EmitterMock } = require('react-native'); const PAYLOAD = { stackTrace: 'android.os.MessageQueue.next(MessageQueue.java:335)\n', frames: [{ funcName: 'android.os.MessageQueue.next', library: 'MessageQueue.java', line: 335 }], + threads: [ + { name: 'worker', frames: [{ funcName: 'java.lang.Object.wait', library: 'Object.java', line: 405 }] }, + { name: 'worker', frames: [{ funcName: 'java.lang.Thread.run', library: 'Thread.java', line: 1572 }] }, + { name: 'main', frames: [{ funcName: 'not.the.fault.thread', library: 'Fake.java', line: 1 }] }, + ], }; function createClient() { @@ -95,6 +100,19 @@ describe('AnrWatchdogHandler', () => { expect(report.stackTrace['main']).toEqual(PAYLOAD.frames); }); + it('adds the other threads without overwriting the fault thread, renaming duplicates', () => { + const client = createClient(); + AnrWatchdogHandler.create()?.start(client as never, 0, false); + + EmitterMock.emit(PAYLOAD); + + const report = client.send.mock.calls[0][0]; + expect(report.stackTrace['main']).toEqual(PAYLOAD.frames); + expect(report.stackTrace['worker']).toEqual(PAYLOAD.threads[0].frames); + expect(report.stackTrace['worker-2']).toEqual(PAYLOAD.threads[1].frames); + expect(report.stackTrace['main-2']).toEqual(PAYLOAD.threads[2].frames); + }); + it('pauses detection in the background and resumes it in the foreground', () => { AnrWatchdogHandler.create()?.start(createClient() as never, 3000, true); const [onAppStateChange] = appStateListeners(); From ea76483136db472b78dcb11d19151b213070f12e Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Wed, 26 Aug 2026 15:39:32 +0200 Subject: [PATCH 08/13] react-native: update README, add missed exception logging --- packages/react-native/README.md | 4 ++-- .../src/main/java/backtrace/library/BacktraceReactNative.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index e71f2af0..538863c2 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -324,8 +324,8 @@ const options: BacktraceConfiguration = { | ----------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------ | | `enable` | Boolean | Determines if ANR detection is enabled. | `false` |
  • - [ ]
| | `type` | `BacktraceAnrType` | Detection mechanism: `Threshold` or `ApplicationExit`. | `Threshold` |
  • - [ ]
| -| `timeout` | Number | Time in milliseconds the main thread stays blocked before an ANR is reported. Applies to the `threshold` type only. | `5000` |
  • - [ ]
| -| `disableWhenDebuggerAttached` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `threshold` type only. | `false` |
  • - [ ]
| +| `timeout` | Number | Time in milliseconds the main thread stays blocked before an ANR is reported. Applies to the `Threshold` type only. | `5000` |
  • - [ ]
| +| `disableWhenDebuggerAttached` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `Threshold` type only. | `false` |
  • - [ ]
| --- diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java index ad58c1ce..5232c50d 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceReactNative.java @@ -117,7 +117,7 @@ public void getAnrExitInfo(double sinceEpochMillis, Promise promise) { try { promise.resolve(new AnrExitInfoReader(this.context).read((long) sinceEpochMillis)); } catch (Exception e) { - Log.d(this.NAME, "Could not read ANR exit info"); + Log.w(this.NAME, "Could not read ANR exit info", e); promise.reject("backtrace_anr_exit_info", e); } } From fbb1e10aa95d365b74166ec22389936f37a78b4e Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Thu, 27 Aug 2026 16:55:48 +0200 Subject: [PATCH 09/13] react-native: capture all threads in applicationExit reports --- packages/react-native/README.md | 12 ++--- .../backtrace/library/AnrExitInfoReader.java | 30 +++++++++--- .../library/anr/ExitInfoStackTraceParser.java | 42 ++++++++++++++++- .../anr/ExitInfoStackTraceParserTest.java | 47 +++++++++++++++++++ packages/react-native/src/anr/AnrReporter.ts | 3 ++ packages/react-native/src/anr/AnrThreads.ts | 19 ++++++++ .../src/anr/AnrWatchdogHandler.ts | 23 ++------- .../tests/anrReporterTests.spec.ts | 22 ++++++++- 8 files changed, 164 insertions(+), 34 deletions(-) create mode 100644 packages/react-native/src/anr/AnrThreads.ts diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 538863c2..5e629c35 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -295,18 +295,18 @@ client.metrics?.send(); ### ANR Detection The Backtrace react-native SDK can detect Application Not Responding errors on Android and report them with the -`Hang` error type. The report carries the main thread stack trace from the moment the hang was observed. +`Hang` error type. The report carries the stack traces of every thread, with the blocked main thread as the +faulting one. Two detection mechanisms are available: - `BacktraceAnrType.Threshold` monitors the main thread from a separate thread. If the main thread remains unresponsive for longer than the configured timeout (default: 5 seconds), an ANR is reported immediately, while - the application is still hung. The report carries the stack traces of every Java thread, with the blocked main - thread as the faulting one. It works on all supported Android versions. + the application is still hung. It works on all supported Android versions. - `BacktraceAnrType.ApplicationExit` retrieves the ANRs the system recorded for previous runs of the process from - `ApplicationExitInfo` and reports them on the next application start, including the thread dump the system - captured when it declared the ANR. It requires API 30 or above and survives the process kill. Reported records - are remembered, so each ANR is reported once. + `ApplicationExitInfo` and reports them on the next application start, using the thread dump the system captured + when it declared the ANR. The full dump is also attached as `anr-stacktrace.txt`. It requires API 30 or above + and survives the process kill. Reported records are remembered, so each ANR is reported once. ```ts import { BacktraceAnrType, BacktraceConfiguration } from '@backtrace/react-native'; diff --git a/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java b/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java index b4188af7..d946cde1 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java +++ b/packages/react-native/android/src/main/java/backtrace/library/AnrExitInfoReader.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -83,25 +84,42 @@ private WritableMap describe(ExitInfo exitInfo) { record.putMap("attributes", toWritableMap(AppExitInfoDetailsExtractor.getANRAttributes(exitInfo))); record.putString("stackTrace", stackTrace); - StackTraceElement[] frames = parseMainThreadFrames(stackTrace); + Map parsed = parseStackTrace(stackTrace); + + StackTraceElement[] frames = ExitInfoStackTraceParser.parseMainThreadStackTrace(parsed); if (frames.length > 0) { record.putArray("mainThreadFrames", StackFrameMapper.toWritableFrames(frames)); } + + WritableArray threads = toWritableThreads(ExitInfoStackTraceParser.parseOtherThreadStackTraces(parsed)); + if (threads.size() > 0) { + record.putArray("threads", threads); + } return record; } - private StackTraceElement[] parseMainThreadFrames(String stackTrace) { + private Map parseStackTrace(String stackTrace) { if (stackTrace == null || stackTrace.isEmpty()) { - return new StackTraceElement[0]; + return new HashMap<>(); } try { - Map parsed = ExitInfoStackTraceParser.parseANRStackTrace(stackTrace); - return ExitInfoStackTraceParser.parseMainThreadStackTrace(parsed); + return ExitInfoStackTraceParser.parseANRStackTrace(stackTrace); } catch (Exception e) { Log.e(LOG_TAG, "Could not parse the ANR stack trace", e); - return new StackTraceElement[0]; + return new HashMap<>(); + } + } + + private WritableArray toWritableThreads(List threads) { + WritableArray result = new WritableNativeArray(); + for (ExitInfoStackTraceParser.ThreadStackTrace thread : threads) { + WritableMap map = new WritableNativeMap(); + map.putString("name", thread.getName()); + map.putArray("frames", StackFrameMapper.toWritableFrames(thread.getFrames())); + result.pushMap(map); } + return result; } private WritableMap toWritableMap(Map values) { diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java index c6efec73..ca4c2891 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java @@ -65,8 +65,48 @@ public static StackTraceElement[] parseMainThreadStackTrace(Map return new StackTraceElement[0]; } - List stackFrames = (List) mainThreadInfo.get("stack_trace"); + return toStackTraceElements((List) mainThreadInfo.get("stack_trace")); + } + + // the dump repeats names across pool threads, so these stay a list and are deduplicated by the caller + public static List parseOtherThreadStackTraces(Map parsedData) { + List result = new ArrayList<>(); + List> threads = (List>) parsedData.get("threads"); + + if (threads == null) { + return result; + } + + for (Map thread : threads) { + Object name = thread.get("name"); + if (name == null || MAIN_THREAD_NAME.equals(name)) { + continue; + } + result.add(new ThreadStackTrace(name.toString(), + toStackTraceElements((List) thread.get("stack_trace")))); + } + return result; + } + + public static class ThreadStackTrace { + private final String name; + private final StackTraceElement[] frames; + + ThreadStackTrace(String name, StackTraceElement[] frames) { + this.name = name; + this.frames = frames; + } + + public String getName() { + return this.name; + } + + public StackTraceElement[] getFrames() { + return this.frames; + } + } + private static StackTraceElement[] toStackTraceElements(List stackFrames) { if (stackFrames == null) { return new StackTraceElement[0]; } diff --git a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java index d9d8fafe..dd64964a 100644 --- a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java +++ b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java @@ -1,8 +1,10 @@ package backtraceio.library.anr; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.io.BufferedReader; import java.io.IOException; @@ -131,6 +133,51 @@ public void parseAnrMainThreadStackTrace() { assertEquals("backtraceio.backtraceio.MainActivity", anrMainThreadStacktrace[21].getClassName()); } + @Test + public void parseAnrOtherThreadStackTraces() { + // GIVEN + String stacktrace = readResource(ANR_APPEXIT_STACKTRACE_FILE); + Map parsed = ExitInfoStackTraceParser.parseANRStackTrace(stacktrace); + + // WHEN + List threads = + ExitInfoStackTraceParser.parseOtherThreadStackTraces(parsed); + + // THEN + assertNotNull(threads); + assertFalse(threads.isEmpty()); + + List names = new ArrayList<>(); + for (ExitInfoStackTraceParser.ThreadStackTrace thread : threads) { + names.add(thread.getName()); + } + assertFalse(names.contains("main")); + assertTrue(names.contains("Signal Catcher")); + } + + @Test + public void parseOtherThreadStackTracesKeepsRepeatedNames() { + // GIVEN + String dump = "\"OkHttp Dispatcher\" daemon prio=5 tid=12 Waiting\n" + + " at java.lang.Object.wait(Object.java:405)\n" + + "\n" + + "\"OkHttp Dispatcher\" daemon prio=5 tid=13 Waiting\n" + + " at java.lang.Thread.run(Thread.java:1572)\n" + + "\n"; + Map parsed = ExitInfoStackTraceParser.parseANRStackTrace(dump); + + // WHEN + List threads = + ExitInfoStackTraceParser.parseOtherThreadStackTraces(parsed); + + // THEN + assertEquals(2, threads.size()); + assertEquals("OkHttp Dispatcher", threads.get(0).getName()); + assertEquals("OkHttp Dispatcher", threads.get(1).getName()); + assertEquals("wait", threads.get(0).getFrames()[0].getMethodName()); + assertEquals("run", threads.get(1).getFrames()[0].getMethodName()); + } + private static String readResource(String fileName) { ClassLoader classLoader = ExitInfoStackTraceParserTest.class.getClassLoader(); InputStream inputStream = classLoader.getResourceAsStream(fileName); diff --git a/packages/react-native/src/anr/AnrReporter.ts b/packages/react-native/src/anr/AnrReporter.ts index 4fec72cc..55136a72 100644 --- a/packages/react-native/src/anr/AnrReporter.ts +++ b/packages/react-native/src/anr/AnrReporter.ts @@ -4,6 +4,7 @@ import type { BacktraceClient } from '../BacktraceClient'; import { DebuggerHelper } from '../common/DebuggerHelper'; import type { FileSystem } from '../storage/FileSystem'; import { AnrException } from './AnrException'; +import { addOtherThreads, type AnrThread } from './AnrThreads'; export interface AnrExitInfoRecord { timestamp: number; @@ -11,6 +12,7 @@ export interface AnrExitInfoRecord { attributes: Record; stackTrace?: string; mainThreadFrames?: BacktraceStackFrame[]; + threads?: AnrThread[]; } export class AnrReporter { @@ -81,6 +83,7 @@ export class AnrReporter { ); report.addStackTrace('main', record.mainThreadFrames as BacktraceStackFrame[]); + addOtherThreads(report, record.threads); return report; } diff --git a/packages/react-native/src/anr/AnrThreads.ts b/packages/react-native/src/anr/AnrThreads.ts new file mode 100644 index 00000000..778849e3 --- /dev/null +++ b/packages/react-native/src/anr/AnrThreads.ts @@ -0,0 +1,19 @@ +import { BacktraceReport, type BacktraceStackFrame } from '@backtrace/sdk-core'; + +export interface AnrThread { + name: string; + frames: BacktraceStackFrame[]; +} + +export function addOtherThreads(report: BacktraceReport, threads?: AnrThread[]): void { + const usedNames = new Set(['main']); + for (const thread of threads ?? []) { + const base = thread.name || 'unknown'; + let name = base; + for (let ordinal = 2; usedNames.has(name); ordinal++) { + name = `${base}-${ordinal}`; + } + usedNames.add(name); + report.addStackTrace(name, thread.frames); + } +} diff --git a/packages/react-native/src/anr/AnrWatchdogHandler.ts b/packages/react-native/src/anr/AnrWatchdogHandler.ts index f0c9db72..4afcb1ca 100644 --- a/packages/react-native/src/anr/AnrWatchdogHandler.ts +++ b/packages/react-native/src/anr/AnrWatchdogHandler.ts @@ -10,18 +10,14 @@ import { import type { BacktraceClient } from '../BacktraceClient'; import { DebuggerHelper } from '../common/DebuggerHelper'; import { AnrException } from './AnrException'; +import { addOtherThreads, type AnrThread } from './AnrThreads'; const AnrDetectedEvent = 'BacktraceAnrDetected'; -interface AnrThreadPayload { - name: string; - frames: BacktraceStackFrame[]; -} - interface AnrDetectedPayload { stackTrace: string; frames: BacktraceStackFrame[]; - threads?: AnrThreadPayload[]; + threads?: AnrThread[]; } export class AnrWatchdogHandler { @@ -59,7 +55,7 @@ export class AnrWatchdogHandler { [], ); report.addStackTrace('main', payload.frames); - this.addOtherThreads(report, payload.threads); + addOtherThreads(report, payload.threads); client.send(report); }, ); @@ -76,19 +72,6 @@ export class AnrWatchdogHandler { this._watchdog.start(timeout, disableWhenDebuggerAttached); } - private addOtherThreads(report: BacktraceReport, threads?: AnrThreadPayload[]): void { - const usedNames = new Set(['main']); - for (const thread of threads ?? []) { - const base = thread.name || 'unknown'; - let name = base; - for (let ordinal = 2; usedNames.has(name); ordinal++) { - name = `${base}-${ordinal}`; - } - usedNames.add(name); - report.addStackTrace(name, thread.frames); - } - } - public dispose(): void { this._subscription?.remove(); this._subscription = undefined; diff --git a/packages/react-native/tests/anrReporterTests.spec.ts b/packages/react-native/tests/anrReporterTests.spec.ts index b58ee0af..3b5ba564 100644 --- a/packages/react-native/tests/anrReporterTests.spec.ts +++ b/packages/react-native/tests/anrReporterTests.spec.ts @@ -47,11 +47,16 @@ function record(timestamp: number, frames?: object[]) { attributes: { PID: 123 }, stackTrace: 'raw dump', mainThreadFrames: frames, - }; + } as { threads?: object[] } & Record; } const FRAMES = [{ funcName: 'android.os.MessageQueue.next', library: 'MessageQueue.java', line: 335 }]; +const OTHER_THREADS = [ + { name: 'FinalizerDaemon', frames: [{ funcName: 'java.lang.Object.wait', library: 'Object.java', line: 405 }] }, + { name: 'FinalizerDaemon', frames: [{ funcName: 'java.lang.Thread.run', library: 'Thread.java', line: 1572 }] }, +]; + describe('AnrReporter', () => { beforeEach(() => { getAnrExitInfo.mockReset(); @@ -131,4 +136,19 @@ describe('AnrReporter', () => { expect(report.attachments[0].name).toBe('anr-stacktrace.txt'); expect(report.attributes['PID']).toBe(123); }); + + it('attaches the other threads from the dump and renames collisions', async () => { + const fileSystem = createFileSystem(); + const anr = record(1, FRAMES); + anr.threads = OTHER_THREADS; + getAnrExitInfo.mockResolvedValue([anr]); + const send = jest.fn().mockResolvedValue({ status: 'Ok' }); + + await AnrReporter.create(fileSystem)?.report(createClient(send)); + + const report = send.mock.calls[0][0]; + expect(report.stackTrace['main']).toEqual(FRAMES); + expect(report.stackTrace['FinalizerDaemon']).toEqual(OTHER_THREADS[0].frames); + expect(report.stackTrace['FinalizerDaemon-2']).toEqual(OTHER_THREADS[1].frames); + }); }); From e415644f47d3d2cbae505c1656f6f3adb63aa194 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Fri, 28 Aug 2026 14:53:38 +0200 Subject: [PATCH 10/13] react-native: keep native method frames when parsing the ANR dump --- .../library/anr/ExitInfoStackTraceParser.java | 23 ++++++++++++-- .../anr/ExitInfoStackTraceParserTest.java | 30 +++++++++++++++---- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java index ca4c2891..0e6e31d1 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java @@ -12,6 +12,8 @@ public class ExitInfoStackTraceParser { private static final Pattern JAVA_FRAME_PATTERN = Pattern.compile("\\s*at (.*?)\\((.*?):(\\d+)\\)"); + private static final Pattern NATIVE_METHOD_FRAME_PATTERN = Pattern.compile("\\s*at (.*?)\\(Native method\\)"); + private static final int NATIVE_METHOD_LINE_NUMBER = -2; private static final String MAIN_THREAD_NAME = "main"; private static final int NATIVE_STACK_ELEMENTS_NUMBER = 6; @@ -21,9 +23,24 @@ static StackTraceElement parseFrame(String frame) { return javaFrame; } + StackTraceElement nativeMethodFrame = parseNativeMethodFrame(frame); + if (nativeMethodFrame != null) { + return nativeMethodFrame; + } + return parseNativeFrame(frame); } + static StackTraceElement parseNativeMethodFrame(String frame) { + Matcher matcher = NATIVE_METHOD_FRAME_PATTERN.matcher(frame); + if (!matcher.find()) { + return null; + } + + // isNativeMethod, and so the reported library, keys off this line number + return toStackTraceElement(matcher.group(1), null, NATIVE_METHOD_LINE_NUMBER); + } + static StackTraceElement parseNativeFrame(String frame) { if (!frame.startsWith("native")) { return null; @@ -47,10 +64,10 @@ static StackTraceElement parseJavaFrame(String frame) { return null; } - String fullClassNameMethod = matcher.group(1); - String fileName = matcher.group(2); - int lineNumber = Integer.parseInt(matcher.group(3)); + return toStackTraceElement(matcher.group(1), matcher.group(2), Integer.parseInt(matcher.group(3))); + } + private static StackTraceElement toStackTraceElement(String fullClassNameMethod, String fileName, int lineNumber) { int lastDot = fullClassNameMethod.lastIndexOf('.'); String className = (lastDot == -1) ? fullClassNameMethod : fullClassNameMethod.substring(0, lastDot); String methodName = (lastDot == -1) ? "" : fullClassNameMethod.substring(lastDot + 1); diff --git a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java index dd64964a..adb4b8a9 100644 --- a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java +++ b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java @@ -113,7 +113,7 @@ public void parseAnrMainThreadStackTrace() { StackTraceElement[] anrMainThreadStacktrace = ExitInfoStackTraceParser.parseMainThreadStackTrace(anrStacktrace); // THEN - assertEquals(33, anrMainThreadStacktrace.length); + assertEquals(36, anrMainThreadStacktrace.length); assertEquals("(__kernel_vsyscall+7)", anrMainThreadStacktrace[0].getMethodName()); assertEquals(0, anrMainThreadStacktrace[0].getLineNumber()); @@ -127,10 +127,30 @@ public void parseAnrMainThreadStackTrace() { assertEquals("address: 00379b00", anrMainThreadStacktrace[14].getFileName()); assertEquals("/apex/com.android.art/lib/libart.so", anrMainThreadStacktrace[14].getClassName()); - assertEquals("handledException", anrMainThreadStacktrace[21].getMethodName()); - assertEquals(157, anrMainThreadStacktrace[21].getLineNumber()); - assertEquals("MainActivity.java", anrMainThreadStacktrace[21].getFileName()); - assertEquals("backtraceio.backtraceio.MainActivity", anrMainThreadStacktrace[21].getClassName()); + assertEquals("yield", anrMainThreadStacktrace[21].getMethodName()); + assertEquals("java.lang.Thread", anrMainThreadStacktrace[21].getClassName()); + assertTrue(anrMainThreadStacktrace[21].isNativeMethod()); + + assertEquals("handledException", anrMainThreadStacktrace[22].getMethodName()); + assertEquals(157, anrMainThreadStacktrace[22].getLineNumber()); + assertEquals("MainActivity.java", anrMainThreadStacktrace[22].getFileName()); + assertEquals("backtraceio.backtraceio.MainActivity", anrMainThreadStacktrace[22].getClassName()); + } + + @Test + public void parseFrameNativeMethod() { + // GIVEN + String frame = " at java.lang.Thread.sleep(Native method)"; + + // WHEN + StackTraceElement element = ExitInfoStackTraceParser.parseFrame(frame); + + // THEN + assertNotNull(element); + assertEquals("java.lang.Thread", element.getClassName()); + assertEquals("sleep", element.getMethodName()); + assertNull(element.getFileName()); + assertTrue(element.isNativeMethod()); } @Test From 3bb6e66a0f66f8089cdc5928182d32fd6b5259a8 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Fri, 28 Aug 2026 15:15:15 +0200 Subject: [PATCH 11/13] react-native: parse fileless Java frames, update whitespace detection after at, keep native frame addresses --- .../library/anr/ExitInfoStackTraceParser.java | 27 ++++++- .../library/anr/StackFrameMapper.java | 1 + .../anr/ExitInfoStackTraceParserTest.java | 72 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java index 0e6e31d1..4cc3a66c 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/ExitInfoStackTraceParser.java @@ -11,9 +11,12 @@ import java.util.regex.Pattern; public class ExitInfoStackTraceParser { - private static final Pattern JAVA_FRAME_PATTERN = Pattern.compile("\\s*at (.*?)\\((.*?):(\\d+)\\)"); - private static final Pattern NATIVE_METHOD_FRAME_PATTERN = Pattern.compile("\\s*at (.*?)\\(Native method\\)"); + private static final Pattern JAVA_FRAME_PATTERN = Pattern.compile("\\s*at\\s+(.*?)\\((.*?):(\\d+)\\)"); + private static final Pattern NATIVE_METHOD_FRAME_PATTERN = Pattern.compile("\\s*at\\s+(.*?)\\(Native [Mm]ethod\\)"); + private static final Pattern FILE_ONLY_FRAME_PATTERN = Pattern.compile("\\s*at\\s+(.*?)\\(([^)]+)\\)"); + private static final String UNKNOWN_SOURCE_FILE = "Unknown Source"; private static final int NATIVE_METHOD_LINE_NUMBER = -2; + private static final int UNKNOWN_LINE_NUMBER = -1; private static final String MAIN_THREAD_NAME = "main"; private static final int NATIVE_STACK_ELEMENTS_NUMBER = 6; @@ -28,7 +31,25 @@ static StackTraceElement parseFrame(String frame) { return nativeMethodFrame; } - return parseNativeFrame(frame); + StackTraceElement nativeFrame = parseNativeFrame(frame); + if (nativeFrame != null) { + return nativeFrame; + } + + return parseFileOnlyFrame(frame); + } + + static StackTraceElement parseFileOnlyFrame(String frame) { + Matcher matcher = FILE_ONLY_FRAME_PATTERN.matcher(frame); + if (!matcher.find()) { + return null; + } + + String fileName = matcher.group(2); + if (UNKNOWN_SOURCE_FILE.equals(fileName)) { + fileName = null; + } + return toStackTraceElement(matcher.group(1), fileName, UNKNOWN_LINE_NUMBER); } static StackTraceElement parseNativeMethodFrame(String frame) { diff --git a/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java b/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java index 6f334217..601fe618 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java +++ b/packages/react-native/android/src/main/java/backtrace/library/anr/StackFrameMapper.java @@ -33,6 +33,7 @@ private static WritableMap toWritableFrame(StackTraceElement frame) { // parseNativeFrame stores the symbol in the method name and the library in the class name map.putString("funcName", frame.getMethodName()); map.putString("library", frame.getClassName()); + map.putString("address", fileName.substring(NATIVE_FILE_PREFIX.length())); return map; } diff --git a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java index adb4b8a9..2d0306ed 100644 --- a/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java +++ b/packages/react-native/android/src/test/java/backtraceio/library/anr/ExitInfoStackTraceParserTest.java @@ -153,6 +153,78 @@ public void parseFrameNativeMethod() { assertTrue(element.isNativeMethod()); } + @Test + public void parseFrameNativeMethodCapitalized() { + // GIVEN + String frame = " at java.lang.Thread.yield(Native Method)"; + + // WHEN + StackTraceElement element = ExitInfoStackTraceParser.parseFrame(frame); + + // THEN + assertNotNull(element); + assertEquals("yield", element.getMethodName()); + assertTrue(element.isNativeMethod()); + } + + @Test + public void parseFrameUnknownSource() { + // GIVEN + String frame = " at com.example.Foo.bar(Unknown Source)"; + + // WHEN + StackTraceElement element = ExitInfoStackTraceParser.parseFrame(frame); + + // THEN + assertNotNull(element); + assertEquals("com.example.Foo", element.getClassName()); + assertEquals("bar", element.getMethodName()); + assertNull(element.getFileName()); + assertFalse(element.isNativeMethod()); + } + + @Test + public void parseFrameFileWithoutLine() { + // GIVEN + String frame = " at com.example.Foo.bar(SourceFile)"; + + // WHEN + StackTraceElement element = ExitInfoStackTraceParser.parseFrame(frame); + + // THEN + assertNotNull(element); + assertEquals("SourceFile", element.getFileName()); + assertEquals(-1, element.getLineNumber()); + } + + @Test + public void parseFrameFileWithColonAndNoLine() { + // GIVEN + String frame = " at com.example.Foo.bar(weird:file)"; + + // WHEN + StackTraceElement element = ExitInfoStackTraceParser.parseFrame(frame); + + // THEN + assertNotNull(element); + assertEquals("weird:file", element.getFileName()); + assertEquals(-1, element.getLineNumber()); + } + + @Test + public void parseFrameWithTabAfterAt() { + // GIVEN + String frame = "\tat\tjava.lang.Thread.sleep(Thread.java:580)"; + + // WHEN + StackTraceElement element = ExitInfoStackTraceParser.parseFrame(frame); + + // THEN + assertNotNull(element); + assertEquals("sleep", element.getMethodName()); + assertEquals(580, element.getLineNumber()); + } + @Test public void parseAnrOtherThreadStackTraces() { // GIVEN From 6bd35bb055ccc642e575260f134fd7606560fc5f Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Fri, 28 Aug 2026 16:09:38 +0200 Subject: [PATCH 12/13] react-native: skip ANR re-reads for database-retried sends --- packages/react-native/src/anr/AnrReporter.ts | 26 ++++++++++++++++--- .../tests/anrReporterTests.spec.ts | 23 ++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/react-native/src/anr/AnrReporter.ts b/packages/react-native/src/anr/AnrReporter.ts index 55136a72..60790cc6 100644 --- a/packages/react-native/src/anr/AnrReporter.ts +++ b/packages/react-native/src/anr/AnrReporter.ts @@ -1,4 +1,9 @@ -import { BacktraceReport, BacktraceStringAttachment, type BacktraceStackFrame } from '@backtrace/sdk-core'; +import { + BacktraceReport, + BacktraceStringAttachment, + type BacktraceStackFrame, + type BacktraceSubmissionStatus, +} from '@backtrace/sdk-core'; import { NativeModules, Platform } from 'react-native'; import type { BacktraceClient } from '../BacktraceClient'; import { DebuggerHelper } from '../common/DebuggerHelper'; @@ -17,6 +22,13 @@ export interface AnrExitInfoRecord { export class AnrReporter { private static readonly MarkerFileName = 'backtrace-anr-marker'; + private static readonly DatabaseRetriedStatuses: BacktraceSubmissionStatus[] = [ + 'Network Error', + 'Server Error', + 'Invalid token', + 'Unknown', + 'Unsupported', + ]; private constructor( private readonly _fileSystem: FileSystem, @@ -50,7 +62,7 @@ export class AnrReporter { await this.readMarker(), ); - // records arrive oldest first, so stopping on failure leaves the rest for the next launch + // records arrive oldest first, so stopping leaves the rest for the next launch for (const record of records) { if (!record.mainThreadFrames?.length) { // no groupable stack, but still mark it seen or it is re-read on every launch @@ -59,7 +71,7 @@ export class AnrReporter { } const result = await client.send(this.buildReport(record)); - if (result.status !== 'Ok') { + if (this.needsRetry(client, result.status)) { return; } @@ -70,6 +82,14 @@ export class AnrReporter { } } + // the database stores every report before the send attempt and retries failures from there + private needsRetry(client: BacktraceClient, status: BacktraceSubmissionStatus): boolean { + if (status === 'Ok') { + return false; + } + return !client.database || !AnrReporter.DatabaseRetriedStatuses.includes(status); + } + private buildReport(record: AnrExitInfoRecord): BacktraceReport { // an exception stack is copied into the error annotation, so the dump goes only to the attachment const report = new BacktraceReport( diff --git a/packages/react-native/tests/anrReporterTests.spec.ts b/packages/react-native/tests/anrReporterTests.spec.ts index 3b5ba564..47264095 100644 --- a/packages/react-native/tests/anrReporterTests.spec.ts +++ b/packages/react-native/tests/anrReporterTests.spec.ts @@ -112,6 +112,29 @@ describe('AnrReporter', () => { expect(fileSystem.storage.get(MARKER_PATH)).toBe('1'); }); + it('advances the marker on a failed send that the database stores for retry', async () => { + const fileSystem = createFileSystem(); + getAnrExitInfo.mockResolvedValue([record(1, FRAMES), record(2, FRAMES)]); + const send = jest.fn().mockResolvedValue({ status: 'Network Error' }); + const client = { send, database: {} } as never; + + await AnrReporter.create(fileSystem)?.report(client); + + expect(send).toHaveBeenCalledTimes(2); + expect(fileSystem.storage.get(MARKER_PATH)).toBe('2'); + }); + + it('keeps the marker on a rate-limited send even with a database', async () => { + const fileSystem = createFileSystem(); + getAnrExitInfo.mockResolvedValue([record(1, FRAMES)]); + const send = jest.fn().mockResolvedValue({ status: 'Limit reached' }); + const client = { send, database: {} } as never; + + await AnrReporter.create(fileSystem)?.report(client); + + expect(fileSystem.storage.get(MARKER_PATH)).toBeUndefined(); + }); + it('skips a record without frames but still advances the marker', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(7, undefined)]); From 30c05f48bdf6f583f922f7e17157e47c61436a58 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Fri, 28 Aug 2026 16:11:26 +0200 Subject: [PATCH 13/13] react-native: rename the ANR marker to last timestamp --- packages/react-native/src/anr/AnrReporter.ts | 24 ++++++++--------- .../tests/anrReporterTests.spec.ts | 26 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/react-native/src/anr/AnrReporter.ts b/packages/react-native/src/anr/AnrReporter.ts index 60790cc6..2ff93667 100644 --- a/packages/react-native/src/anr/AnrReporter.ts +++ b/packages/react-native/src/anr/AnrReporter.ts @@ -21,7 +21,7 @@ export interface AnrExitInfoRecord { } export class AnrReporter { - private static readonly MarkerFileName = 'backtrace-anr-marker'; + private static readonly LastTimestampFileName = 'backtrace-anr-last-timestamp'; private static readonly DatabaseRetriedStatuses: BacktraceSubmissionStatus[] = [ 'Network Error', 'Server Error', @@ -32,7 +32,7 @@ export class AnrReporter { private constructor( private readonly _fileSystem: FileSystem, - private readonly _markerPath: string, + private readonly _lastTimestampPath: string, ) {} public static create(fileSystem: FileSystem): AnrReporter | undefined { @@ -53,20 +53,20 @@ export class AnrReporter { return undefined; } - return new AnrReporter(fileSystem, `${applicationDirectory}/${AnrReporter.MarkerFileName}`); + return new AnrReporter(fileSystem, `${applicationDirectory}/${AnrReporter.LastTimestampFileName}`); } public async report(client: BacktraceClient): Promise { try { const records: AnrExitInfoRecord[] = await NativeModules.BacktraceReactNative.getAnrExitInfo( - await this.readMarker(), + await this.readLastTimestamp(), ); // records arrive oldest first, so stopping leaves the rest for the next launch for (const record of records) { if (!record.mainThreadFrames?.length) { - // no groupable stack, but still mark it seen or it is re-read on every launch - await this.writeMarker(record.timestamp); + // no groupable stack, but still save its timestamp or it is re-read on every launch + await this.saveLastTimestamp(record.timestamp); continue; } @@ -75,7 +75,7 @@ export class AnrReporter { return; } - await this.writeMarker(record.timestamp); + await this.saveLastTimestamp(record.timestamp); } } catch (err) { console.warn('Backtrace: cannot report ANRs from application exit info.', err); @@ -107,16 +107,16 @@ export class AnrReporter { return report; } - private async readMarker(): Promise { - if (!(await this._fileSystem.exists(this._markerPath))) { + private async readLastTimestamp(): Promise { + if (!(await this._fileSystem.exists(this._lastTimestampPath))) { return 0; } - const timestamp = parseInt(await this._fileSystem.readFile(this._markerPath), 10); + const timestamp = parseInt(await this._fileSystem.readFile(this._lastTimestampPath), 10); return Number.isFinite(timestamp) ? timestamp : 0; } - private writeMarker(timestamp: number): Promise { - return this._fileSystem.writeFile(this._markerPath, timestamp.toString()); + private saveLastTimestamp(timestamp: number): Promise { + return this._fileSystem.writeFile(this._lastTimestampPath, timestamp.toString()); } } diff --git a/packages/react-native/tests/anrReporterTests.spec.ts b/packages/react-native/tests/anrReporterTests.spec.ts index 47264095..4c3d852f 100644 --- a/packages/react-native/tests/anrReporterTests.spec.ts +++ b/packages/react-native/tests/anrReporterTests.spec.ts @@ -21,7 +21,7 @@ NativeModules.BacktraceReactNative = { getAnrExitInfo }; const { AnrReporter } = require('../src/anr/AnrReporter'); /* eslint-enable @typescript-eslint/no-var-requires */ -const MARKER_PATH = '/data/backtrace-anr-marker'; +const LAST_TIMESTAMP_PATH = '/data/backtrace-anr-last-timestamp'; function createFileSystem(files: Record = {}) { const storage = new Map(Object.entries(files)); @@ -75,8 +75,8 @@ describe('AnrReporter', () => { NativeModules.BacktraceReactNative = saved; }); - it('passes the stored marker to the native reader', async () => { - const fileSystem = createFileSystem({ [MARKER_PATH]: '1234' }); + it('passes the stored last timestamp to the native reader', async () => { + const fileSystem = createFileSystem({ [LAST_TIMESTAMP_PATH]: '1234' }); getAnrExitInfo.mockResolvedValue([]); await AnrReporter.create(fileSystem)?.report(createClient()); @@ -84,7 +84,7 @@ describe('AnrReporter', () => { expect(getAnrExitInfo).toHaveBeenCalledWith(1234); }); - it('sends records oldest first and advances the marker after each accepted send', async () => { + it('sends records oldest first and saves the timestamp after each accepted send', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(1, FRAMES), record(2, FRAMES)]); const send = jest.fn().mockResolvedValue({ status: 'Ok' }); @@ -95,10 +95,10 @@ describe('AnrReporter', () => { expect(send.mock.calls[0][0].attributes['error.type']).toBe('Hang'); expect(send.mock.calls[0][0].timestamp).toBe(1); expect(send.mock.calls[1][0].timestamp).toBe(2); - expect(fileSystem.storage.get(MARKER_PATH)).toBe('2'); + expect(fileSystem.storage.get(LAST_TIMESTAMP_PATH)).toBe('2'); }); - it('stops on a failed send and keeps the marker at the last accepted record', async () => { + it('stops on a failed send and keeps the last timestamp at the last accepted record', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(1, FRAMES), record(2, FRAMES), record(3, FRAMES)]); const send = jest @@ -109,10 +109,10 @@ describe('AnrReporter', () => { await AnrReporter.create(fileSystem)?.report(createClient(send)); expect(send).toHaveBeenCalledTimes(2); - expect(fileSystem.storage.get(MARKER_PATH)).toBe('1'); + expect(fileSystem.storage.get(LAST_TIMESTAMP_PATH)).toBe('1'); }); - it('advances the marker on a failed send that the database stores for retry', async () => { + it('saves the timestamp on a failed send that the database stores for retry', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(1, FRAMES), record(2, FRAMES)]); const send = jest.fn().mockResolvedValue({ status: 'Network Error' }); @@ -121,10 +121,10 @@ describe('AnrReporter', () => { await AnrReporter.create(fileSystem)?.report(client); expect(send).toHaveBeenCalledTimes(2); - expect(fileSystem.storage.get(MARKER_PATH)).toBe('2'); + expect(fileSystem.storage.get(LAST_TIMESTAMP_PATH)).toBe('2'); }); - it('keeps the marker on a rate-limited send even with a database', async () => { + it('keeps the last timestamp on a rate-limited send even with a database', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(1, FRAMES)]); const send = jest.fn().mockResolvedValue({ status: 'Limit reached' }); @@ -132,10 +132,10 @@ describe('AnrReporter', () => { await AnrReporter.create(fileSystem)?.report(client); - expect(fileSystem.storage.get(MARKER_PATH)).toBeUndefined(); + expect(fileSystem.storage.get(LAST_TIMESTAMP_PATH)).toBeUndefined(); }); - it('skips a record without frames but still advances the marker', async () => { + it('skips a record without frames but still saves its timestamp', async () => { const fileSystem = createFileSystem(); getAnrExitInfo.mockResolvedValue([record(7, undefined)]); const send = jest.fn(); @@ -143,7 +143,7 @@ describe('AnrReporter', () => { await AnrReporter.create(fileSystem)?.report(createClient(send)); expect(send).not.toHaveBeenCalled(); - expect(fileSystem.storage.get(MARKER_PATH)).toBe('7'); + expect(fileSystem.storage.get(LAST_TIMESTAMP_PATH)).toBe('7'); }); it('attaches the parsed frames as the main thread and the raw dump as an attachment', async () => {