Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions examples/sdk/reactNative/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ BacktraceClient.initialize({
prop2: 123,
},
},
anr: {
enable: true,
},
database: {
enable: true,
captureNativeCrashes: true,
Expand Down
10 changes: 10 additions & 0 deletions examples/sdk/reactNative/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
6 changes: 1 addition & 5 deletions examples/sdk/reactNative/src/actions/android/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ export const actions: DemoAction[] = __DEV__
throw new Error('Native modules are not enabled.');
}

await new Promise<void>((res) => {
setTimeout(() => {
res();
}, 1000);
});
errorGenerator.blockMainThread(10000);
},
},
{
Expand Down
38 changes: 38 additions & 0 deletions packages/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -291,6 +292,43 @@ 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 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. 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, 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';

const options: BacktraceConfiguration = {
url: SUBMISSION_URL,
anr: {
enable: true,
type: BacktraceAnrType.Threshold,
},
};
```

| Option Name | Type | Description | Default | Required? |
| ----------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------ |
| `enable` | Boolean | Determines if ANR detection is enabled. | `false` | <ul><li>- [ ] </li></ul> |
| `type` | `BacktraceAnrType` | Detection mechanism: `Threshold` or `ApplicationExit`. | `Threshold` | <ul><li>- [ ] </li></ul> |
| `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> |
| `disableWhenDebuggerAttached` | Boolean | When true, detection is disabled while a debugger is attached. Applies to the `Threshold` type only. | `false` | <ul><li>- [ ] </li></ul> |

---

### Offline database support

The Backtrace react-native SDK can cache generated reports and crashes to local disk before sending them to Backtrace.
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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.HashMap;
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;
import backtraceio.library.anr.StackFrameMapper;

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<ExitInfo> exitInfos = this.exitInfoProvider.getHistoricalExitInfo(
this.context.getPackageName(), 0, ALL_EXIT_RECORDS);
List<Integer> supportedReasons = this.exitInfoProvider.getSupportedTypesOfExitInfo();

// oldest first, so a caller advancing a last-seen timestamp cannot skip a record
List<ExitInfo> 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);

Map<String, Object> 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 Map<String, Object> parseStackTrace(String stackTrace) {
if (stackTrace == null || stackTrace.isEmpty()) {
return new HashMap<>();
}

try {
return ExitInfoStackTraceParser.parseANRStackTrace(stackTrace);
} catch (Exception e) {
Log.e(LOG_TAG, "Could not parse the ANR stack trace", e);
return new HashMap<>();
}
}

private WritableArray toWritableThreads(List<ExitInfoStackTraceParser.ThreadStackTrace> 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<String, Object> values) {
WritableMap map = new WritableNativeMap();
for (Map.Entry<String, Object> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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;

@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;
}

@Override
public void invalidate() {
stop();
super.invalidate();
}

@ReactMethod()
public void addListener(String eventName) {}

@ReactMethod()
public void removeListeners(Integer count) {}

private void emitAnrDetected(Map<Thread, StackTraceElement[]> 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<Thread, StackTraceElement[]> 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);
}
}
Loading
Loading