Skip to content

Commit 9c33420

Browse files
committed
Merge branch 'main' into fix/rn-breadcrumb
2 parents 9ccc6be + 5502f0c commit 9c33420

31 files changed

Lines changed: 2446 additions & 8 deletions

‎.github/workflows/android.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ jobs:
6969
- name: Inject the CI crash driver into the example entry point
7070
run: cat .github/scripts/ci-crash-driver.js >> examples/sdk/reactNative/index.js
7171

72+
- name: Run SDK unit tests
73+
working-directory: examples/sdk/reactNative/android
74+
run: ./gradlew :backtrace_react-native:testDebugUnitTest -PnewArchEnabled=${{ matrix.new-arch }} --console=plain
75+
7276
- name: Build example app
7377
working-directory: examples/sdk/reactNative/android
7478
run: ./gradlew assembleRelease -PbtCiDebuggable -PnewArchEnabled=${{ matrix.new-arch }} -x uploadSourceMapsToBacktrace --console=plain

‎examples/sdk/reactNative/android/app/src/main/java/com/reactnative/ErrorGenerator.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import java.io.FileReader;
99
import java.io.IOException;
1010

11+
import android.os.Handler;
12+
import android.os.Looper;
1113
import android.util.Log;
1214

1315
public class ErrorGenerator extends ReactContextBaseJavaModule {
@@ -25,6 +27,16 @@ public void throwError() throws IOException {
2527
readUserConfiguration();
2628
}
2729

30+
@ReactMethod
31+
public void blockMainThread(int durationMs) {
32+
new Handler(Looper.getMainLooper()).post(() -> {
33+
try {
34+
Thread.sleep(durationMs);
35+
} catch (InterruptedException ignored) {
36+
}
37+
});
38+
}
39+
2840
private void readUserConfiguration() throws IOException {
2941
// I know for sure this file is there (spoiler alert, it's not)
3042
File mConfiguration = new File("configuration.json");

‎examples/sdk/reactNative/index.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ BacktraceClient.initialize({
1515
prop2: 123,
1616
},
1717
},
18+
anr: {
19+
enable: true,
20+
},
1821
database: {
1922
enable: true,
2023
captureNativeCrashes: true,

‎examples/sdk/reactNative/metro.config.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ const backtraceSourceMapProcessor = require('@backtrace/react-native/scripts/pro
99
* @type {import('metro-config').MetroConfig}
1010
*/
1111
const config = {
12+
resolver: {
13+
blockList: [
14+
// a second react-native copy splits the JS event registry from the native one
15+
new RegExp(path.resolve(__dirname, '../../../packages/react-native/node_modules/react-native').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*'),
16+
new RegExp(path.resolve(__dirname, '../../../node_modules/react-native').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*'),
17+
],
18+
extraNodeModules: {
19+
'react-native': path.resolve(__dirname, 'node_modules/react-native'),
20+
},
21+
},
1222
watchFolders: [
1323
path.resolve('../../../packages/react-native'),
1424
path.resolve('../../../packages/react-native/node_modules'),

‎examples/sdk/reactNative/src/actions/android/action.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ export const actions: DemoAction[] = __DEV__
1313
throw new Error('Native modules are not enabled.');
1414
}
1515

16-
await new Promise<void>((res) => {
17-
setTimeout(() => {
18-
res();
19-
}, 1000);
20-
});
16+
errorGenerator.blockMainThread(10000);
2117
},
2218
},
2319
{

‎packages/react-native/README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and easy, after which you can explore the rich set of Backtrace features.
1919
- [Application Stability Metrics](#application-stability-metrics)
2020
- [Metrics Configuration](#metrics-configuration)
2121
- [Metrics Usage](#metrics-usage)
22+
- [ANR Detection](#anr-detection)
2223
- [Offline Database support](#offline-database-support)
2324
- [Database Configuration](#database-configuration)
2425
- [Native crash support](#native-crash-support)
@@ -291,6 +292,43 @@ client.metrics?.send();
291292

292293
---
293294

295+
### ANR Detection
296+
297+
The Backtrace react-native SDK can detect Application Not Responding errors on Android and report them with the
298+
`Hang` error type. The report carries the stack traces of every thread, with the blocked main thread as the
299+
faulting one.
300+
301+
Two detection mechanisms are available:
302+
303+
- `BacktraceAnrType.Threshold` monitors the main thread from a separate thread. If the main thread remains
304+
unresponsive for longer than the configured timeout (default: 5 seconds), an ANR is reported immediately, while
305+
the application is still hung. It works on all supported Android versions.
306+
- `BacktraceAnrType.ApplicationExit` retrieves the ANRs the system recorded for previous runs of the process from
307+
`ApplicationExitInfo` and reports them on the next application start, using the thread dump the system captured
308+
when it declared the ANR. The full dump is also attached as `anr-stacktrace.txt`. It requires API 30 or above
309+
and survives the process kill. Reported records are remembered, so each ANR is reported once.
310+
311+
```ts
312+
import { BacktraceAnrType, BacktraceConfiguration } from '@backtrace/react-native';
313+
314+
const options: BacktraceConfiguration = {
315+
url: SUBMISSION_URL,
316+
anr: {
317+
enable: true,
318+
type: BacktraceAnrType.Threshold,
319+
},
320+
};
321+
```
322+
323+
| Option Name | Type | Description | Default | Required? |
324+
| ----------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------ |
325+
| `enable` | Boolean | Determines if ANR detection is enabled. | `false` | <ul><li>- [ ] </li></ul> |
326+
| `type` | `BacktraceAnrType` | Detection mechanism: `Threshold` or `ApplicationExit`. | `Threshold` | <ul><li>- [ ] </li></ul> |
327+
| `timeout` | Number | Time in milliseconds the main thread stays blocked before an ANR is reported. Applies to the `Threshold` type only. | `5000` | <ul><li>- [ ] </li></ul> |
328+
| `disableWhenDebuggerAttached` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `Threshold` type only. | `false` | <ul><li>- [ ] </li></ul> |
329+
330+
---
331+
294332
### Offline database support
295333

296334
The Backtrace react-native SDK can cache generated reports and crashes to local disk before sending them to Backtrace.

‎packages/react-native/android/build.gradle‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ dependencies {
8181
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
8282
//noinspection GradleDynamicVersion
8383
implementation "com.facebook.react:react-native:+"
84+
testImplementation "junit:junit:4.13.2"
8485
}
8586

8687
if (isNewArchitectureEnabled()) {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package backtraceio.library;
2+
3+
import android.content.Context;
4+
import android.os.Build;
5+
import android.util.Log;
6+
7+
import androidx.annotation.RequiresApi;
8+
9+
import com.facebook.react.bridge.WritableArray;
10+
import com.facebook.react.bridge.WritableMap;
11+
import com.facebook.react.bridge.WritableNativeArray;
12+
import com.facebook.react.bridge.WritableNativeMap;
13+
14+
import java.util.ArrayList;
15+
import java.util.Collections;
16+
import java.util.HashMap;
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
import backtraceio.library.anr.ActivityManagerExitInfoProvider;
21+
import backtraceio.library.anr.AppExitInfoDetailsExtractor;
22+
import backtraceio.library.anr.ExitInfo;
23+
import backtraceio.library.anr.ExitInfoStackTraceParser;
24+
import backtraceio.library.anr.ProcessExitInfoProvider;
25+
import backtraceio.library.anr.StackFrameMapper;
26+
27+
class AnrExitInfoReader {
28+
private final static transient String LOG_TAG = AnrExitInfoReader.class.getSimpleName();
29+
30+
private static final int ALL_EXIT_RECORDS = 0;
31+
32+
private final Context context;
33+
private final ProcessExitInfoProvider exitInfoProvider;
34+
35+
AnrExitInfoReader(Context context) {
36+
this(context, new ActivityManagerExitInfoProvider(context));
37+
}
38+
39+
AnrExitInfoReader(Context context, ProcessExitInfoProvider exitInfoProvider) {
40+
this.context = context;
41+
this.exitInfoProvider = exitInfoProvider;
42+
}
43+
44+
WritableArray read(long sinceEpochMillis) {
45+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
46+
return new WritableNativeArray();
47+
}
48+
return readAnrRecords(sinceEpochMillis);
49+
}
50+
51+
@RequiresApi(Build.VERSION_CODES.R)
52+
private WritableArray readAnrRecords(long sinceEpochMillis) {
53+
WritableArray records = new WritableNativeArray();
54+
55+
List<ExitInfo> exitInfos = this.exitInfoProvider.getHistoricalExitInfo(
56+
this.context.getPackageName(), 0, ALL_EXIT_RECORDS);
57+
List<Integer> supportedReasons = this.exitInfoProvider.getSupportedTypesOfExitInfo();
58+
59+
// oldest first, so a caller advancing a last-seen timestamp cannot skip a record
60+
List<ExitInfo> ordered = new ArrayList<>(exitInfos);
61+
Collections.reverse(ordered);
62+
63+
for (ExitInfo exitInfo : ordered) {
64+
if (!supportedReasons.contains(exitInfo.getReason())) {
65+
continue;
66+
}
67+
if (exitInfo.getTimestamp() <= sinceEpochMillis) {
68+
continue;
69+
}
70+
records.pushMap(describe(exitInfo));
71+
}
72+
73+
return records;
74+
}
75+
76+
@RequiresApi(Build.VERSION_CODES.R)
77+
private WritableMap describe(ExitInfo exitInfo) {
78+
String stackTrace = AppExitInfoDetailsExtractor.getStackTraceInfo(exitInfo);
79+
80+
WritableMap record = new WritableNativeMap();
81+
// putInt would overflow epoch millis
82+
record.putDouble("timestamp", (double) exitInfo.getTimestamp());
83+
record.putString("message", AppExitInfoDetailsExtractor.getANRMessage(exitInfo));
84+
record.putMap("attributes", toWritableMap(AppExitInfoDetailsExtractor.getANRAttributes(exitInfo)));
85+
record.putString("stackTrace", stackTrace);
86+
87+
Map<String, Object> parsed = parseStackTrace(stackTrace);
88+
89+
StackTraceElement[] frames = ExitInfoStackTraceParser.parseMainThreadStackTrace(parsed);
90+
if (frames.length > 0) {
91+
record.putArray("mainThreadFrames", StackFrameMapper.toWritableFrames(frames));
92+
}
93+
94+
WritableArray threads = toWritableThreads(ExitInfoStackTraceParser.parseOtherThreadStackTraces(parsed));
95+
if (threads.size() > 0) {
96+
record.putArray("threads", threads);
97+
}
98+
return record;
99+
}
100+
101+
private Map<String, Object> parseStackTrace(String stackTrace) {
102+
if (stackTrace == null || stackTrace.isEmpty()) {
103+
return new HashMap<>();
104+
}
105+
106+
try {
107+
return ExitInfoStackTraceParser.parseANRStackTrace(stackTrace);
108+
} catch (Exception e) {
109+
Log.e(LOG_TAG, "Could not parse the ANR stack trace", e);
110+
return new HashMap<>();
111+
}
112+
}
113+
114+
private WritableArray toWritableThreads(List<ExitInfoStackTraceParser.ThreadStackTrace> threads) {
115+
WritableArray result = new WritableNativeArray();
116+
for (ExitInfoStackTraceParser.ThreadStackTrace thread : threads) {
117+
WritableMap map = new WritableNativeMap();
118+
map.putString("name", thread.getName());
119+
map.putArray("frames", StackFrameMapper.toWritableFrames(thread.getFrames()));
120+
result.pushMap(map);
121+
}
122+
return result;
123+
}
124+
125+
private WritableMap toWritableMap(Map<String, Object> values) {
126+
WritableMap map = new WritableNativeMap();
127+
for (Map.Entry<String, Object> entry : values.entrySet()) {
128+
String key = entry.getKey();
129+
Object value = entry.getValue();
130+
131+
if (value instanceof String) {
132+
map.putString(key, (String) value);
133+
} else if (value instanceof Integer) {
134+
map.putInt(key, (Integer) value);
135+
} else if (value instanceof Long || value instanceof Double || value instanceof Float) {
136+
map.putDouble(key, ((Number) value).doubleValue());
137+
} else if (value instanceof Boolean) {
138+
map.putBoolean(key, (Boolean) value);
139+
} else if (value == null) {
140+
map.putNull(key);
141+
} else {
142+
map.putString(key, value.toString());
143+
}
144+
}
145+
return map;
146+
}
147+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package backtraceio.library;
2+
3+
import android.os.Looper;
4+
5+
import androidx.annotation.NonNull;
6+
7+
import com.facebook.react.bridge.ReactApplicationContext;
8+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
9+
import com.facebook.react.bridge.ReactMethod;
10+
import com.facebook.react.bridge.WritableArray;
11+
import com.facebook.react.bridge.WritableMap;
12+
import com.facebook.react.bridge.WritableNativeArray;
13+
import com.facebook.react.bridge.WritableNativeMap;
14+
import com.facebook.react.module.annotations.ReactModule;
15+
16+
import java.util.Map;
17+
18+
import backtraceio.library.anr.AnrWatchdog;
19+
import backtraceio.library.anr.StackFrameMapper;
20+
21+
@ReactModule(name = BacktraceAnrWatchdog.NAME)
22+
public class BacktraceAnrWatchdog extends ReactContextBaseJavaModule {
23+
public static final String NAME = "BacktraceAnrWatchdog";
24+
public static final String ANR_DETECTED_EVENT = "BacktraceAnrDetected";
25+
26+
private AnrWatchdog watchdog;
27+
28+
public BacktraceAnrWatchdog(ReactApplicationContext reactContext) {
29+
super(reactContext);
30+
}
31+
32+
@Override
33+
@NonNull
34+
public String getName() {
35+
return NAME;
36+
}
37+
38+
@ReactMethod()
39+
public void start(int timeout, boolean debug) {
40+
if (this.watchdog != null) {
41+
return;
42+
}
43+
44+
this.watchdog = new AnrWatchdog(
45+
timeout > 0 ? timeout : AnrWatchdog.DEFAULT_ANR_TIMEOUT,
46+
debug,
47+
this::emitAnrDetected);
48+
}
49+
50+
@ReactMethod()
51+
public void stop() {
52+
if (this.watchdog == null) {
53+
return;
54+
}
55+
56+
this.watchdog.stopMonitoring();
57+
this.watchdog = null;
58+
}
59+
60+
@Override
61+
public void invalidate() {
62+
stop();
63+
super.invalidate();
64+
}
65+
66+
@ReactMethod()
67+
public void addListener(String eventName) {}
68+
69+
@ReactMethod()
70+
public void removeListeners(Integer count) {}
71+
72+
private void emitAnrDetected(Map<Thread, StackTraceElement[]> allThreads) {
73+
ReactApplicationContext context = getReactApplicationContext();
74+
if (!context.hasActiveReactInstance()) {
75+
return;
76+
}
77+
78+
Thread mainThread = Looper.getMainLooper().getThread();
79+
StackTraceElement[] mainThreadFrames = allThreads.get(mainThread);
80+
if (mainThreadFrames == null) {
81+
mainThreadFrames = mainThread.getStackTrace();
82+
}
83+
84+
WritableArray threads = new WritableNativeArray();
85+
for (Map.Entry<Thread, StackTraceElement[]> entry : allThreads.entrySet()) {
86+
if (entry.getKey() == mainThread) {
87+
continue;
88+
}
89+
WritableMap thread = new WritableNativeMap();
90+
thread.putString("name", entry.getKey().getName());
91+
thread.putArray("frames", StackFrameMapper.toWritableFrames(entry.getValue()));
92+
threads.pushMap(thread);
93+
}
94+
95+
WritableMap event = new WritableNativeMap();
96+
event.putString("stackTrace", StackFrameMapper.toFormattedString(mainThreadFrames));
97+
event.putArray("frames", StackFrameMapper.toWritableFrames(mainThreadFrames));
98+
event.putArray("threads", threads);
99+
100+
// getJSModule(RCTDeviceEventEmitter) drops events silently in bridgeless mode
101+
context.emitDeviceEvent(ANR_DETECTED_EVENT, event);
102+
}
103+
}

0 commit comments

Comments
 (0)