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 2aaf0e4dd..c9d037a25 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,6 +1,7 @@ package backtraceio.library; import android.os.Looper; +import android.util.Log; import androidx.annotation.NonNull; @@ -41,10 +42,14 @@ public void start(int timeout, boolean debug) { return; } - this.watchdog = new AnrWatchdog( - timeout > 0 ? timeout : AnrWatchdog.DEFAULT_ANR_TIMEOUT, - debug, - this::emitAnrDetected); + try { + this.watchdog = new AnrWatchdog( + timeout > 0 ? timeout : AnrWatchdog.DEFAULT_ANR_TIMEOUT, + debug, + this::emitAnrDetected); + } catch (RuntimeException | OutOfMemoryError e) { + Log.w(NAME, "Failed to start the ANR watchdog (" + e.getClass().getName() + ")"); + } } @ReactMethod() @@ -53,7 +58,11 @@ public void stop() { return; } - this.watchdog.stopMonitoring(); + try { + this.watchdog.stopMonitoring(); + } catch (RuntimeException e) { + Log.w(NAME, "Failed to stop the ANR watchdog (" + e.getClass().getName() + ")"); + } this.watchdog = null; } @@ -70,8 +79,16 @@ public void addListener(String eventName) {} public void removeListeners(Integer count) {} private void emitAnrDetected(Map allThreads) { + try { + emitAnrEvent(allThreads); + } catch (RuntimeException e) { + Log.w(NAME, "Failed to report an ANR (" + e.getClass().getName() + ")"); + } + } + + private void emitAnrEvent(Map allThreads) { ReactApplicationContext context = getReactApplicationContext(); - if (!context.hasActiveReactInstance()) { + if (context == null || !context.hasActiveReactInstance()) { return; } diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceApplicationAttributeProvider.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceApplicationAttributeProvider.java index ef505e984..b2930f6e6 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceApplicationAttributeProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceApplicationAttributeProvider.java @@ -33,8 +33,12 @@ public String getName() { @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap get() { WritableMap map = new WritableNativeMap(); - map.putString("application", this.readApplicationName()); - map.putString("application.version", this.readApplicationVersion()); + try { + map.putString("application", this.readApplicationName()); + map.putString("application.version", this.readApplicationVersion()); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Could not read the application attributes: " + e.getClass().getName()); + } return map; } diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceDeviceAttributeProvider.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceDeviceAttributeProvider.java index 1d328a97b..a961d9c25 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceDeviceAttributeProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceDeviceAttributeProvider.java @@ -11,12 +11,14 @@ import android.content.Context; import android.os.Build; +import android.util.Log; import java.util.Locale; @ReactModule(name = BacktraceDeviceAttributeProvider.NAME) public class BacktraceDeviceAttributeProvider extends ReactContextBaseJavaModule { public static final String NAME = "BacktraceDeviceAttributeProvider"; + private static final String LOG_TAG = BacktraceDeviceAttributeProvider.class.getSimpleName(); private final Context context; @@ -34,14 +36,18 @@ public String getName() { @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap get() { WritableMap map = new WritableNativeMap(); - map.putString("culture", this.readCulture()); - map.putString("device.model", this.getDeviceModel()); - map.putString("device.brand", this.getDeviceBrand()); - map.putString("device.product", this.getDeviceProduct()); - map.putString("device.sdk", this.getDeviceSdk()); - map.putString("device.manufacturer", this.getDeviceManufacturer()); - map.putString("cpu.boottime", String.valueOf(java.lang.System.currentTimeMillis() - android.os.SystemClock - .elapsedRealtime())); + try { + map.putString("culture", this.readCulture()); + map.putString("device.model", this.getDeviceModel()); + map.putString("device.brand", this.getDeviceBrand()); + map.putString("device.product", this.getDeviceProduct()); + map.putString("device.sdk", this.getDeviceSdk()); + map.putString("device.manufacturer", this.getDeviceManufacturer()); + map.putString("cpu.boottime", String.valueOf(java.lang.System.currentTimeMillis() - android.os.SystemClock + .elapsedRealtime())); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Could not read all device attributes: " + e.getClass().getName()); + } return map; } diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceDirectoryProvider.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceDirectoryProvider.java index 4047befc1..4209152c3 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceDirectoryProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceDirectoryProvider.java @@ -1,6 +1,7 @@ package backtrace.library; import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; @@ -13,12 +14,12 @@ import com.facebook.react.module.annotations.ReactModule; import java.io.File; -import java.util.stream.Stream; @ReactModule(name = BacktraceDirectoryProvider.NAME) public class BacktraceDirectoryProvider extends ReactContextBaseJavaModule { public static final String NAME = "BacktraceDirectoryProvider"; + private static final String LOG_TAG = BacktraceDirectoryProvider.class.getSimpleName(); private final Context context; @@ -35,51 +36,57 @@ public String getName() { @ReactMethod(isBlockingSynchronousMethod = true) public WritableArray readDirSync(String path) { - File file = new File(path); - - WritableArray array = new WritableNativeArray(); - for (File directoryFile : file.listFiles()) { - array.pushString(directoryFile.getName()); - } - - return array; + return listDirectory(path); } @ReactMethod public void readDir(String path, Promise promise) { - File file = new File(path); - - WritableArray array = new WritableNativeArray(); - for (File directoryFile : file.listFiles()) { - array.pushString(directoryFile.getName()); - } - - promise.resolve(array); + promise.resolve(listDirectory(path)); } @ReactMethod(isBlockingSynchronousMethod = true) public boolean createDirSync(String path) { - File directory = new File(path); - - if (directory.exists()) { - return true; - } - return directory.mkdirs(); + return createDirectory(path); } @ReactMethod public void createDir(String path, Promise promise) { - File directory = new File(path); - - if (directory.exists()) { - promise.resolve(true); - return; - } - promise.resolve(directory.mkdirs()); + promise.resolve(createDirectory(path)); } @ReactMethod(isBlockingSynchronousMethod = true) public String applicationDirectory() { - return context.getFilesDir().getAbsolutePath(); + try { + return context.getFilesDir().getAbsolutePath(); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Cannot resolve the application files directory: " + e.getClass().getName()); + return ""; + } + } + + private WritableArray listDirectory(String path) { + WritableArray array = new WritableNativeArray(); + try { + File[] files = new File(path).listFiles(); + if (files == null) { + return array; + } + for (File directoryFile : files) { + array.pushString(directoryFile.getName()); + } + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Cannot list directory " + path + ": " + e.getClass().getName()); + } + return array; + } + + private boolean createDirectory(String path) { + try { + File directory = new File(path); + return directory.exists() || directory.mkdirs(); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Cannot create directory " + path + ": " + e.getClass().getName()); + return false; + } } } diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java index 3f9664793..5fbd7cbbb 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceFileSystemProvider.java @@ -102,35 +102,41 @@ public void writeFile(String path, String content, Promise promise) { @ReactMethod(isBlockingSynchronousMethod = true) public boolean unlinkSync(String path) { - File file = new File(path); - if (!file.exists()) { - return true; - } - Boolean result = file.delete(); - return result; + return delete(path); } @ReactMethod public void unlink(String filePath, Promise promise) { - File file = new File(filePath); - if (!file.exists()) { - promise.resolve(true); - return; - } - Boolean result = file.delete(); - promise.resolve(result); + promise.resolve(delete(filePath)); } @ReactMethod(isBlockingSynchronousMethod = true) public boolean existsSync(String path) { - File file = new File(path); - return file.exists(); + return exists(path); } @ReactMethod public void exists(String path, Promise promise) { - File file = new File(path); - promise.resolve(file.exists()); + promise.resolve(exists(path)); + } + + private boolean delete(String path) { + try { + File file = new File(path); + return !file.exists() || file.delete(); + } catch (RuntimeException e) { + Log.d(LOG_TAG, "Cannot delete " + path + ": " + e.getClass().getName()); + return false; + } + } + + private boolean exists(String path) { + try { + return new File(path).exists(); + } catch (RuntimeException e) { + Log.d(LOG_TAG, "Cannot check " + path + ": " + e.getClass().getName()); + return false; + } } @ReactMethod(isBlockingSynchronousMethod = true) 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 5232c50d2..2229f014d 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 @@ -14,10 +14,11 @@ import android.content.Context; -import java.io.File; -import java.util.Arrays; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; import backtraceio.library.nativeCalls.*; @@ -27,18 +28,18 @@ @ReactModule(name = BacktraceReactNative.NAME) public class BacktraceReactNative extends ReactContextBaseJavaModule { - static { - System.loadLibrary("backtrace-native"); - } - public static final String NAME = "BacktraceReactNative"; + private static final boolean nativeLibraryLoaded = loadNativeLibrary(); + public native void Crash(); private final Context context; private final Set registeredAttachments = new HashSet<>(); + private volatile boolean initialized = false; + public BacktraceReactNative(ReactApplicationContext reactContext) { super(reactContext); this.context = reactContext.getApplicationContext(); @@ -50,59 +51,100 @@ public String getName() { return NAME; } + private static boolean loadNativeLibrary() { + try { + System.loadLibrary("backtrace-native"); + return true; + } catch (UnsatisfiedLinkError | SecurityException e) { + Log.w(NAME, "libbacktrace-native did not load, native crash reporting is off (" + e.getClass().getName() + ")"); + return false; + } + } @ReactMethod(isBlockingSynchronousMethod = true) public Boolean initialize(String minidumpSubmissionUrl, String databasePath, ReadableMap readableAttributes, ReadableArray attachmentPaths) { - Log.d(this.NAME, "Initializing native crash reporter"); - CrashHandlerConfiguration crashHandlerConfiguration = new backtraceio.library.models.nativeHandler.CrashHandlerConfiguration(); - if (!crashHandlerConfiguration.isSupportedAbi()) { - Log.d(this.NAME, "Unsupported ABI detected."); + Log.d(NAME, "Initializing native crash reporter"); + if (!nativeLibraryLoaded) { + Log.w(NAME, "libbacktrace-native did not load, native crash reporting is off"); return false; } - - HashMap attributes = readableAttributes.toHashMap(); - String[] keys = attributes.keySet().toArray(new String[0]); - String[] values = attributes.values().toArray(new String[0]); - - String[] attachments = attachmentPaths.toArrayList().toArray(new String[0]); - - BacktraceCrashHandlerWrapper nativeCommunication = new BacktraceCrashHandlerWrapper(); - Boolean result = nativeCommunication.initializeJavaCrashHandler( - minidumpSubmissionUrl, - databasePath, - crashHandlerConfiguration.getClassPath(), - keys, - values, - attachments, - crashHandlerConfiguration.getCrashHandlerEnvironmentVariables(this.context.getApplicationInfo()).toArray(new String[0]) - ); - - this.registeredAttachments.addAll(Arrays.asList(attachments)); - return result; + try { + CrashHandlerConfiguration crashHandlerConfiguration = new CrashHandlerConfiguration(); + if (!crashHandlerConfiguration.isSupportedAbi()) { + Log.w(NAME, "Unsupported ABI, native crash reporting is off"); + return false; + } + + Map attributes = readableAttributes != null ? readableAttributes.toHashMap() : new HashMap(); + String[] keys = new String[attributes.size()]; + String[] values = new String[attributes.size()]; + int index = 0; + for (Map.Entry attribute : attributes.entrySet()) { + keys[index] = attribute.getKey(); + values[index] = stringValue(attribute.getValue()); + index++; + } + + List attachments = stringList(attachmentPaths); + + BacktraceCrashHandlerWrapper nativeCommunication = new BacktraceCrashHandlerWrapper(); + boolean result = nativeCommunication.initializeJavaCrashHandler( + minidumpSubmissionUrl, + databasePath, + crashHandlerConfiguration.getClassPath(), + keys, + values, + attachments.toArray(new String[0]), + crashHandlerConfiguration.getCrashHandlerEnvironmentVariables(this.context.getApplicationInfo()).toArray(new String[0]) + ); + + if (!result) { + Log.w(NAME, "The native crash handler did not start"); + return false; + } + + this.registeredAttachments.addAll(attachments); + this.initialized = true; + return true; + } catch (RuntimeException | LinkageError e) { + Log.w(NAME, "Native crash reporting is off (" + e.getClass().getName() + ")"); + return false; + } } @ReactMethod() public void useAttributes(ReadableMap readableAttributes) { - HashMap attributes = readableAttributes.toHashMap(); + if (!this.initialized || readableAttributes == null) { + return; + } - String[] keys = attributes.keySet().toArray(new String[0]); - String[] values = attributes.values().toArray(new String[0]); - for (int attributeIndex = 0; attributeIndex < attributes.size(); attributeIndex++) { - BacktraceDatabase.addAttribute(keys[attributeIndex], values[attributeIndex]); + try { + for (Map.Entry attribute : readableAttributes.toHashMap().entrySet()) { + BacktraceDatabase.addAttribute(attribute.getKey(), stringValue(attribute.getValue())); + } + } catch (RuntimeException | LinkageError e) { + Log.w(NAME, "Failed to update native attributes (" + e.getClass().getName() + ")"); } } @ReactMethod() public void useAttachments(ReadableArray attachmentPaths) { - for (int attachmentIndex = 0; attachmentIndex < attachmentPaths.size(); attachmentIndex++) { - String attachmentPath = attachmentPaths.getString(attachmentIndex); - if (attachmentPath == null || !this.registeredAttachments.add(attachmentPath)) { - continue; + if (!this.initialized || attachmentPaths == null) { + return; + } + + try { + for (String attachmentPath : stringList(attachmentPaths)) { + if (!this.registeredAttachments.add(attachmentPath)) { + continue; + } + BacktraceDatabase.addAttachment(attachmentPath); } - BacktraceDatabase.addAttachment(attachmentPath); + } catch (RuntimeException | LinkageError e) { + Log.w(NAME, "Failed to update native attachments (" + e.getClass().getName() + ")"); } } @@ -121,4 +163,25 @@ public void getAnrExitInfo(double sinceEpochMillis, Promise promise) { promise.reject("backtrace_anr_exit_info", e); } } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static List stringList(ReadableArray array) { + List result = new ArrayList<>(); + if (array == null) { + return result; + } + for (int index = 0; index < array.size(); index++) { + if (array.isNull(index)) { + continue; + } + String value = array.getString(index); + if (value != null) { + result.add(value); + } + } + return result; + } } diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceSystemAttributeProvider.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceSystemAttributeProvider.java index 9846f5a8f..2cc46ffa0 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceSystemAttributeProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceSystemAttributeProvider.java @@ -12,6 +12,7 @@ import android.os.Build; import android.text.TextUtils; import android.provider.Settings; +import android.util.Log; import java.util.UUID; @@ -20,6 +21,7 @@ @ReactModule(name = BacktraceSystemAttributeProvider.NAME) public class BacktraceSystemAttributeProvider extends ReactContextBaseJavaModule { public static final String NAME = "BacktraceSystemAttributeProvider"; + private static final String LOG_TAG = BacktraceSystemAttributeProvider.class.getSimpleName(); private final Context context; @@ -38,11 +40,15 @@ public String getName() { @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap get() { WritableMap map = new WritableNativeMap(); - map.putString("guid", this.readMachineId()); - map.putString("uname.machine", this.readSystemArchitecture()); - map.putString("uname.sysname", "Android"); - map.putString("uname.version", this.readSystemVersion()); - map.putString("uname.release", this.readSystemRelease()); + try { + map.putString("guid", this.readMachineId()); + map.putString("uname.machine", this.readSystemArchitecture()); + map.putString("uname.sysname", "Android"); + map.putString("uname.version", this.readSystemVersion()); + map.putString("uname.release", this.readSystemRelease()); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Could not read all system attributes: " + e.getClass().getName()); + } return map; } diff --git a/packages/react-native/android/src/main/java/backtrace/library/MemoryInformationAttributeProvider.java b/packages/react-native/android/src/main/java/backtrace/library/MemoryInformationAttributeProvider.java index b9b2ff257..7a5736b79 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/MemoryInformationAttributeProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/MemoryInformationAttributeProvider.java @@ -76,9 +76,13 @@ public String getName() { @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap get() { WritableMap map = new WritableNativeMap(); - Map memoryAttributes = AttributeReader.readAttributesFromFile(attributePath, _attributeMapping); - for (Map.Entry entry : memoryAttributes.entrySet()) { - map.putString(entry.getKey(), entry.getValue()); + try { + Map memoryAttributes = AttributeReader.readAttributesFromFile(attributePath, _attributeMapping); + for (Map.Entry entry : memoryAttributes.entrySet()) { + map.putString(entry.getKey(), entry.getValue()); + } + } catch (RuntimeException e) { + android.util.Log.w(NAME, "Could not read the memory attributes: " + e.getClass().getName()); } return map; } diff --git a/packages/react-native/android/src/main/java/backtrace/library/ProcessAttributeProvider.java b/packages/react-native/android/src/main/java/backtrace/library/ProcessAttributeProvider.java index 87a2a49de..9cf1bd937 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/ProcessAttributeProvider.java +++ b/packages/react-native/android/src/main/java/backtrace/library/ProcessAttributeProvider.java @@ -70,10 +70,14 @@ public WritableMap get() { return map; } map.putInt("process.id", processId); - String processAttributes = String.format("/proc/%d/status", processId); - Map memoryAttributes = AttributeReader.readAttributesFromFile(processAttributes, _attributeMapping); - for (Map.Entry entry : memoryAttributes.entrySet()) { - map.putString(entry.getKey(), entry.getValue()); + try { + String processAttributes = String.format("/proc/%d/status", processId); + Map memoryAttributes = AttributeReader.readAttributesFromFile(processAttributes, _attributeMapping); + for (Map.Entry entry : memoryAttributes.entrySet()) { + map.putString(entry.getKey(), entry.getValue()); + } + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Could not read the process attributes: " + e.getClass().getName()); } return map; } diff --git a/packages/react-native/android/src/main/java/backtrace/library/StreamWriter.java b/packages/react-native/android/src/main/java/backtrace/library/StreamWriter.java index 77c763025..bcfdf71f8 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/StreamWriter.java +++ b/packages/react-native/android/src/main/java/backtrace/library/StreamWriter.java @@ -13,7 +13,9 @@ import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; -import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; @ReactModule(name = backtraceio.library.StreamWriter.NAME) @@ -27,14 +29,12 @@ public class StreamWriter extends ReactContextBaseJavaModule { public String getName() { return NAME; } - private HashMap _map = new HashMap<>(); + // create and close run on the JS thread, append on the native modules thread. + private final Map _map = new ConcurrentHashMap<>(); @ReactMethod(isBlockingSynchronousMethod = true) public String create(String filePath) { - String key = String.valueOf(System.currentTimeMillis()); - if (_map.containsKey(key)) { - return null; - } + String key = UUID.randomUUID().toString(); BufferedWriter writer = this.createWriter(filePath); @@ -68,11 +68,10 @@ public void append(String key, String line, Promise promise) { @ReactMethod(isBlockingSynchronousMethod = true) public boolean close(String key) { - BufferedWriter writer = _map.get(key); + BufferedWriter writer = _map.remove(key); if (writer == null) { return true; } - _map.remove(key); try { writer.close(); return true; 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 b0275f4a8..05a1cb98c 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 @@ -33,6 +33,15 @@ public AnrWatchdog(int timeout, boolean debug, OnAnrDetected listener) { @Override public void run() { + // An uncaught exception on this thread would kill the host process. + try { + monitor(); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "The ANR watchdog stopped (" + e.getClass().getName() + ")"); + } + } + + private void monitor() { if (this.debug && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) { Log.w(LOG_TAG, "Detected a debugger connection. ANR watchdog is disabled"); return; @@ -61,7 +70,11 @@ public void run() { } reported = true; - this.listener.onAnrDetected(Thread.getAllStackTraces()); + try { + this.listener.onAnrDetected(Thread.getAllStackTraces()); + } catch (RuntimeException e) { + Log.w(LOG_TAG, "Failed to report an ANR (" + e.getClass().getName() + ")"); + } } } diff --git a/packages/react-native/ios/BacktraceApplicationAttributeProvider.mm b/packages/react-native/ios/BacktraceApplicationAttributeProvider.mm index 247a19e57..a9cda46f5 100644 --- a/packages/react-native/ios/BacktraceApplicationAttributeProvider.mm +++ b/packages/react-native/ios/BacktraceApplicationAttributeProvider.mm @@ -4,10 +4,11 @@ @implementation BacktraceApplicationAttributeProvider RCT_EXPORT_MODULE() RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(get) { NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; - NSString *displayName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; - NSString *bundleName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]; - [dictionary setObject: displayName ? displayName : bundleName forKey: @"application"]; - [dictionary setObject: [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] forKey: @"application.version"]; + NSBundle *bundle = [NSBundle mainBundle]; + NSString *displayName = [bundle objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + NSString *bundleName = [bundle objectForInfoDictionaryKey:@"CFBundleName"]; + dictionary[@"application"] = displayName ?: bundleName; + dictionary[@"application.version"] = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; return dictionary; } diff --git a/packages/react-native/ios/BacktraceCrashReporter.mm b/packages/react-native/ios/BacktraceCrashReporter.mm index 89c3f9ff4..0efb4f8d6 100644 --- a/packages/react-native/ios/BacktraceCrashReporter.mm +++ b/packages/react-native/ios/BacktraceCrashReporter.mm @@ -55,8 +55,8 @@ - (instancetype)initWithBacktraceUrl:(NSString*) submissionUrl andDatabasePath:( if( self = [super init]) { NSLog(@"Backtrace: Initializing native client"); _backtraceApi = [[BacktraceApi alloc] initWithBacktraceUrl:submissionUrl]; - _attachmentsPaths = [attachments mutableCopy]; - _attributes = [attributes mutableCopy]; + _attachmentsPaths = attachments ? [attachments mutableCopy] : [NSMutableArray new]; + _attributes = attributes ? [attributes mutableCopy] : [NSMutableDictionary new]; _crashReporter = [[PLCrashReporter alloc] initWithConfiguration: [[PLCrashReporterConfig alloc] @@ -108,6 +108,9 @@ - (void) markJsFatalError { - (void)useAttachments:(NSArray*) attachments { + if (attachments == nil) { + return; + } _attachmentsPaths = [attachments mutableCopy]; [self saveReportData]; if(_oomWatcher != nil) { @@ -116,6 +119,9 @@ - (void)useAttachments:(NSArray*) attachments { } -(void)setAttributes:(NSDictionary*) attributes { + if (attributes == nil) { + return; + } [_attributes addEntriesFromDictionary:attributes]; [self saveReportData]; if(_oomWatcher != nil) { diff --git a/packages/react-native/ios/BacktraceDeviceAttributeProvider.mm b/packages/react-native/ios/BacktraceDeviceAttributeProvider.mm index 9f2b47c66..c21c073d1 100644 --- a/packages/react-native/ios/BacktraceDeviceAttributeProvider.mm +++ b/packages/react-native/ios/BacktraceDeviceAttributeProvider.mm @@ -8,15 +8,14 @@ @implementation BacktraceDeviceAttributeProvider NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; struct utsname systemInfo; uname(&systemInfo); - - [dictionary setObject: [NSString stringWithCString:systemInfo.machine - encoding:NSUTF8StringEncoding] forKey: @"device.model"]; - [dictionary setObject: @"Apple Inc" forKey: @"device.brand"]; - [dictionary setObject: [[UIDevice currentDevice] model] forKey: @"device.product"]; - [dictionary setObject: @"Apple Inc" forKey: @"device.manufacturer"]; - [dictionary setObject: [[NSBundle mainBundle] infoDictionary][@"DTSDKName"] forKey: @"device.sdk"]; - [dictionary setObject: [[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0] forKey: @"culture"]; - + + dictionary[@"device.model"] = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; + dictionary[@"device.brand"] = @"Apple Inc"; + dictionary[@"device.product"] = [[UIDevice currentDevice] model]; + dictionary[@"device.manufacturer"] = @"Apple Inc"; + dictionary[@"device.sdk"] = [[NSBundle mainBundle] infoDictionary][@"DTSDKName"]; + dictionary[@"culture"] = [[[NSBundle mainBundle] preferredLocalizations] firstObject]; + return dictionary; } diff --git a/packages/react-native/ios/BacktraceReactNative.mm b/packages/react-native/ios/BacktraceReactNative.mm index 5f112928c..cb530ed2c 100644 --- a/packages/react-native/ios/BacktraceReactNative.mm +++ b/packages/react-native/ios/BacktraceReactNative.mm @@ -13,25 +13,42 @@ @implementation BacktraceReactNative if(instance != nil) { return nil; } - instance = [[BacktraceCrashReporter alloc] initWithBacktraceUrl:submissionUrl andDatabasePath: databasePath andAttributes: attributes andOomSupport:TRUE andAttachments:attachmentPaths]; - [instance start]; + @try { + instance = [[BacktraceCrashReporter alloc] initWithBacktraceUrl:submissionUrl andDatabasePath: databasePath andAttributes: (attributes ?: @{}) andOomSupport:TRUE andAttachments:(attachmentPaths ?: @[])]; + if (instance == nil) { + return @NO; + } + [instance start]; + } @catch (NSException *exception) { + NSLog(@"Backtrace: Native crash reporting is off (%@)", exception.name); + instance = nil; + return @NO; + } return nil; } RCT_EXPORT_METHOD(useAttachments: (NSArray*) attachmentPaths) { - if(instance == nil) { + if(instance == nil || attachmentPaths == nil) { return; } - - [instance useAttachments:attachmentPaths]; + + @try { + [instance useAttachments:attachmentPaths]; + } @catch (NSException *exception) { + NSLog(@"Backtrace: Failed to update native attachments (%@)", exception.name); + } } RCT_EXPORT_METHOD(useAttributes: (NSDictionary*) attributes) { - if(instance == nil) { + if(instance == nil || attributes == nil) { return; } - - [instance setAttributes:attributes]; + + @try { + [instance setAttributes:attributes]; + } @catch (NSException *exception) { + NSLog(@"Backtrace: Failed to update native attributes (%@)", exception.name); + } } RCT_EXPORT_METHOD(crash) diff --git a/packages/react-native/ios/OomWatcher.mm b/packages/react-native/ios/OomWatcher.mm index 57968deed..d7a7bd11f 100644 --- a/packages/react-native/ios/OomWatcher.mm +++ b/packages/react-native/ios/OomWatcher.mm @@ -69,7 +69,7 @@ - (void) foregroundNotification { } - (void) updateAttributes:(NSDictionary*) clientAttributes { - NSMutableDictionary* attributes = [clientAttributes mutableCopy]; + NSMutableDictionary* attributes = clientAttributes ? [clientAttributes mutableCopy] : [NSMutableDictionary dictionary]; [attributes setObject:@"OOMException: Out of memory detected." forKey:@"error.message"]; [attributes setObject:@"OOMException" forKey:@"classifiers"]; [attributes setObject:@"OOMException" forKey:@"error.type"]; @@ -77,7 +77,7 @@ - (void) updateAttributes:(NSDictionary*) clientAttributes { } - (void) updateAttachments:(NSArray*) attachments { - [_applicationState setObject:attachments forKey:@"attachments"]; + [_applicationState setObject:(attachments ?: @[]) forKey:@"attachments"]; } - (void) saveLowMemoryWarning { NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970]; @@ -100,11 +100,10 @@ - (void) saveLowMemoryWarning { - (void) setDefaultApplicationState: (NSArray*) attachments andAttributes:(NSDictionary*) attributes { [_applicationState setObject:@"foreground" forKey:@"state"]; - [_applicationState setObject:attachments forKey:@"attachments"]; [_applicationState setObject:[[NSProcessInfo processInfo] operatingSystemVersionString] forKey:@"osVersion"]; - [_applicationState setObject:[[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"] forKey:@"appVersion"]; + _applicationState[@"appVersion"] = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; [_applicationState setObject:[NSNumber numberWithBool:_debugMode] forKey:@"debuggerEnabled"]; - [self updateAttributes:[attributes mutableCopy]]; + [self updateAttributes:attributes]; [self updateAttachments:attachments]; } diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index f3dc5a664..1ca6bdf57 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -5,6 +5,7 @@ import { SubmissionUrlInformation, V8StackTraceConverter, VariableDebugIdMapProvider, + warnFailure, type AttributeType, type BacktraceData, type BacktraceReport, @@ -32,6 +33,8 @@ import { type FileSystem } from './storage/FileSystem'; const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId'; // Must match the symbolication_id query parameter of the mapping file upload. const SYMBOLICATION_ID_ATTRIBUTE_NAME = 'symbolication_id'; +const REQUIRED_APPLICATION_ATTRIBUTES = ['application', 'application.version']; +const INERT_SUBMISSION_URL = 'https://submit.backtrace.io/unavailable/unavailable/json'; export class BacktraceClient extends BacktraceCoreClient { private _crashReporter?: CrashReporter; @@ -43,7 +46,12 @@ export class BacktraceClient extends BacktraceCoreClient } public static get applicationDataPath(): string { - return NativeModules.BacktraceDirectoryProvider?.applicationDirectory() ?? ''; + try { + return NativeModules.BacktraceDirectoryProvider?.applicationDirectory() ?? ''; + } catch (err) { + warnFailure('failed to read the application data path', err); + return ''; + } } constructor(clientSetup: BacktraceClientSetup) { @@ -88,27 +96,53 @@ export class BacktraceClient extends BacktraceCoreClient } public initialize(): void { - const lockId = this.sessionFiles?.lockPreviousSessions(); + this.ensureApplicationAttributes(); + + const lockId = this.guard( + () => this.sessionFiles?.lockPreviousSessions(), + 'failed to lock previous session files', + ); try { - super.initialize(); - this.addProguardSymbolicationId(); - this.captureUnhandledErrors( - this.options.captureUnhandledErrors, - this.options.captureUnhandledPromiseRejections, - ); + try { + super.initialize(); + } catch (err) { + warnFailure('failed to initialize, error reporting is off', err); + return; + } - this._crashReporter = this.initializeNativeCrashReporter(); - this.reportApplicationNotResponding(); + this.guard(() => this.addProguardSymbolicationId(), 'failed to add the proguard symbolication id'); + this.guard( + () => + this.captureUnhandledErrors( + this.options.captureUnhandledErrors, + this.options.captureUnhandledPromiseRejections, + ), + 'unhandled error capture is off', + ); + this._crashReporter = this.guard( + () => this.initializeNativeCrashReporter(), + 'native crash reporting is off', + ); + this.guard(() => this.reportApplicationNotResponding(), 'ANR detection is off'); } finally { - lockId && this.sessionFiles?.unlockPreviousSessions(lockId); + if (lockId) { + this.guard( + () => this.sessionFiles?.unlockPreviousSessions(lockId), + 'failed to unlock previous session files', + ); + } } } public dispose(): void { - this._exceptionHandler.dispose(); - this._anrWatchdogHandler?.dispose(); - this._crashReporter?.dispose(); + this.guard(() => this._exceptionHandler.dispose(), 'failed to restore the error handlers'); + this.guard(() => this._anrWatchdogHandler?.dispose(), 'failed to stop the ANR watchdog'); + this.guard(() => this._crashReporter?.dispose(), 'failed to release the native crash reporter'); super.dispose(); + // super.dispose() only clears BacktraceCoreClient._instance. + if (BacktraceClient._instance === this) { + BacktraceClient._instance = undefined; + } } public static builder(options: BacktraceConfiguration): BacktraceClientBuilder { @@ -117,6 +151,9 @@ export class BacktraceClient extends BacktraceCoreClient /** * Initializes the client. If the client already exists, the available instance * will be returned and all other options will be ignored. + * + * On a failure the returned client is disabled (`enabled` is false) and the reason + * is logged as a warning. * @param options client configuration * @param build builder * @returns backtrace client @@ -128,9 +165,16 @@ export class BacktraceClient extends BacktraceCoreClient if (this.instance) { return this.instance; } - const builder = this.builder(options); - build && build(builder); - this._instance = builder.build(); + + const safeOptions = BacktraceClient.sanitizeOptions(options ?? ({} as BacktraceConfiguration)); + try { + const builder = this.builder(safeOptions); + build && build(builder); + this._instance = builder.build(); + } catch (err) { + warnFailure('failed to create the client, error reporting is off', err); + this._instance = BacktraceClient.createInertClient(); + } return this._instance as BacktraceClient; } @@ -142,6 +186,49 @@ export class BacktraceClient extends BacktraceCoreClient return this._instance as BacktraceClient; } + private static sanitizeOptions(options: BacktraceConfiguration): BacktraceConfiguration { + let result = options; + if (result.database?.enable && !result.database.path) { + warnFailure('database.path is missing, the database and native crash reporting are off'); + result = { ...result, database: { ...result.database, enable: false, createDatabaseDirectory: false } }; + } + if (result.rateLimit !== undefined && result.rateLimit < 0) { + warnFailure('rateLimit is negative, the client rate limit is off'); + result = { ...result, rateLimit: 0 }; + } + return result; + } + + private static createInertClient(): BacktraceClient { + return new BacktraceClient({ + options: { + url: INERT_SUBMISSION_URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + }, + }); + } + + private ensureApplicationAttributes(): void { + const attributes = this.attributeManager.get().attributes; + const missing = REQUIRED_APPLICATION_ATTRIBUTES.filter((name) => !attributes[name]); + if (missing.length === 0) { + return; + } + + warnFailure(`${missing.join(' and ')} not found, reporting "unknown" until set in userAttributes`); + this.attributeManager.add(Object.fromEntries(missing.map((name) => [name, 'unknown']))); + } + + private guard(fn: () => T, message: string): T | undefined { + try { + return fn(); + } catch (err) { + warnFailure(message, err); + return undefined; + } + } + private captureUnhandledErrors(captureUnhandledExceptions = true, captureUnhandledRejections = true) { if (captureUnhandledExceptions) { this._exceptionHandler.captureManagedErrors(this); @@ -216,7 +303,7 @@ export class BacktraceClient extends BacktraceCoreClient const submissionUrl = SubmissionUrlInformation.toJsonReportSubmissionUrl(this.options.url); const crashReporter = new CrashReporter(fileSystem); - crashReporter.initialize( + const initialized = crashReporter.initialize( Platform.select({ ios: SubmissionUrlInformation.toPlCrashReporterSubmissionUrl(submissionUrl), android: SubmissionUrlInformation.toMinidumpSubmissionUrl(submissionUrl), @@ -226,6 +313,6 @@ export class BacktraceClient extends BacktraceCoreClient this.attributeManager.get('scoped').attributes, this.attachments, ); - return crashReporter; + return initialized ? crashReporter : undefined; } } diff --git a/packages/react-native/src/ErrorBoundary.tsx b/packages/react-native/src/ErrorBoundary.tsx index 82fe91e62..4bfba326a 100644 --- a/packages/react-native/src/ErrorBoundary.tsx +++ b/packages/react-native/src/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import { BacktraceReport } from '@backtrace/sdk-core'; +import { BacktraceReport, warnFailure } from '@backtrace/sdk-core'; import { Component, isValidElement, type ErrorInfo, type ReactElement, type ReactNode } from 'react'; import { BacktraceClient } from './BacktraceClient'; @@ -15,19 +15,15 @@ export interface ErrorBoundaryState { } export class ErrorBoundary extends Component { - private _client: BacktraceClient; private COMPONENT_THREAD_NAME = 'component-stack'; constructor(props: ErrorBoundaryProps) { super(props); this.state = { error: undefined, }; - // grabbing here so it will fail fast if BacktraceClient is uninitialized - const client = BacktraceClient.instance; - if (!client) { - throw new Error('BacktraceClient is uninitialized. Call "BacktraceClient.initialize" function first.'); + if (!BacktraceClient.instance) { + warnFailure('ErrorBoundary reports nothing until BacktraceClient.initialize is called'); } - this._client = client; } public static getDerivedStateFromError(error: Error) { @@ -35,13 +31,18 @@ export class ErrorBoundary extends Component { - client.breadcrumbs?.info('ANR detected - thread is blocked'); + try { + 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); - addOtherThreads(report, payload.threads); - client.send(report); + const report = new BacktraceReport( + new AnrException('Application Not Responding | Blocked thread detected', payload.stackTrace), + { 'error.type': 'Hang' }, + [], + ); + report.addStackTrace('main', payload.frames); + addOtherThreads(report, payload.threads); + client.send(report); + } catch (err) { + warnFailure('failed to report an ANR', err); + } }, ); // 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(); + this.stopWatchdog(); } else if (state === 'active') { - this._watchdog.start(timeout, disableWhenDebuggerAttached); + this.startWatchdog(timeout, disableWhenDebuggerAttached); } }); - this._watchdog.start(timeout, disableWhenDebuggerAttached); + this.startWatchdog(timeout, disableWhenDebuggerAttached); } public dispose(): void { @@ -77,6 +81,22 @@ export class AnrWatchdogHandler { this._subscription = undefined; this._appStateSubscription?.remove(); this._appStateSubscription = undefined; - this._watchdog.stop?.(); + this.stopWatchdog(); + } + + private startWatchdog(timeout: number, disableWhenDebuggerAttached: boolean) { + try { + this._watchdog.start(timeout, disableWhenDebuggerAttached); + } catch (err) { + warnFailure('failed to start the ANR watchdog', err); + } + } + + private stopWatchdog() { + try { + this._watchdog.stop?.(); + } catch (err) { + warnFailure('failed to stop the ANR watchdog', err); + } } } diff --git a/packages/react-native/src/breadcrumbs/events/WebRequestEventSubscriber.ts b/packages/react-native/src/breadcrumbs/events/WebRequestEventSubscriber.ts index 068533f67..75e6f97e5 100644 --- a/packages/react-native/src/breadcrumbs/events/WebRequestEventSubscriber.ts +++ b/packages/react-native/src/breadcrumbs/events/WebRequestEventSubscriber.ts @@ -40,7 +40,7 @@ export class WebRequestEventSubscriber implements BreadcrumbsEventSubscriber { readyStateChangeCallback?.apply(this, [event]); }; - xmlHttpRequestOriginalOpenMethod.call(this, method, url, async || true, username, password); + xmlHttpRequestOriginalOpenMethod.call(this, method, url, async ?? true, username, password); }; this._xmlHttpRequestOriginalOpenMethod = xmlHttpRequestOriginalOpenMethod; diff --git a/packages/react-native/src/builder/BacktraceClientBuilder.ts b/packages/react-native/src/builder/BacktraceClientBuilder.ts index 882dc2bbd..c1ba1be4a 100644 --- a/packages/react-native/src/builder/BacktraceClientBuilder.ts +++ b/packages/react-native/src/builder/BacktraceClientBuilder.ts @@ -1,4 +1,4 @@ -import { BacktraceCoreClientBuilder } from '@backtrace/sdk-core'; +import { BacktraceCoreClientBuilder, warnFailure } from '@backtrace/sdk-core'; import { Platform } from 'react-native'; import { NativeAttributeProvider } from '../attributes/NativeAttributeProvider'; import { ReactNativeAttributeProvider } from '../attributes/ReactNativeAttributeProvider'; @@ -45,7 +45,10 @@ export class BacktraceClientBuilder extends BacktraceCoreClientBuilder CrashReporter.BacktraceReactNative.useAttributes(this.convertAttributes(attributes))); } public updateAttachments(attachments: readonly BacktraceAttachment[]) { @@ -64,7 +78,7 @@ export class CrashReporter { if (typeof CrashReporter.BacktraceReactNative.useAttachments !== 'function') { return; } - CrashReporter.BacktraceReactNative.useAttachments(this.convertAttachments(attachments)); + this.update(() => CrashReporter.BacktraceReactNative.useAttachments(this.convertAttachments(attachments))); } public static crash(): void { @@ -84,6 +98,17 @@ export class CrashReporter { this._enabled = false; } + private update(fn: () => void) { + try { + fn(); + } catch (err) { + if (!this._updateFailureLogged) { + this._updateFailureLogged = true; + warnFailure('failed to update native crash report attributes or attachments', err); + } + } + } + /** * Native layer might not support fully all types supported by the JavaScript SDK. The method converts attributes * to model fully supported by the native env diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index 93a4b7c99..58e575059 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -1,4 +1,4 @@ -import { BacktraceReport } from '@backtrace/sdk-core'; +import { BacktraceReport, warnFailure } from '@backtrace/sdk-core'; import { BacktraceClient } from '../BacktraceClient'; import { hermes } from '../common/hermesHelper'; import { CrashReporter } from '../crashReporter/CrashReporter'; @@ -22,18 +22,23 @@ export class UnhandledExceptionHandler implements ExceptionHandler { public captureManagedErrors(client: BacktraceClient) { const globalErrorHandler = ErrorUtils.getGlobalHandler(); ErrorUtils.setGlobalHandler((error: Error, fatal?: boolean) => { - if (!this.enabled) { - return; - } - client.send(error, { - 'error.type': 'Unhandled exception', - fatal, - }); - // iOS: RCTFatal turns a fatal into a native crash the reporter would double-report. - if (fatal) { - CrashReporter.markFatalError(); + // The app's own handler always runs, also after dispose. + try { + if (this.enabled) { + client.send(error, { + 'error.type': 'Unhandled exception', + fatal, + }); + // iOS: RCTFatal turns a fatal into a native crash the reporter would double-report. + if (fatal) { + CrashReporter.markFatalError(); + } + } + } catch (err) { + warnFailure('failed to report an unhandled error', err); + } finally { + globalErrorHandler(error, fatal); } - globalErrorHandler(error, fatal); }); } @@ -47,20 +52,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { if (!this.enabled) { return; } - client.send( - new BacktraceReport( - rejection, - { - 'error.type': 'Unhandled rejection', - unhandledPromiseRejectionId: id, - }, - [], - { - classifiers: ['UnhandledPromiseRejection'], - skipFrames: rejection instanceof Error ? 0 : 1, - }, - ), - ); + this.reportRejection(client, id, rejection); }, }); } else { @@ -72,20 +64,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { allRejections: true, onUnhandled: (id: number, rejection: Error) => { if (this.enabled) { - client.send( - new BacktraceReport( - rejection, - { - 'error.type': 'Unhandled rejection', - unhandledPromiseRejectionId: id, - }, - [], - { - classifiers: ['UnhandledPromiseRejection'], - skipFrames: rejection instanceof Error ? 0 : 1, - }, - ), - ); + this.reportRejection(client, id, rejection); } if (!__DEV__) { return; @@ -128,4 +107,25 @@ export class UnhandledExceptionHandler implements ExceptionHandler { public dispose(): void { this.enabled = false; } + + private reportRejection(client: BacktraceClient, id: number, rejection: Error | string) { + try { + client.send( + new BacktraceReport( + rejection, + { + 'error.type': 'Unhandled rejection', + unhandledPromiseRejectionId: id, + }, + [], + { + classifiers: ['UnhandledPromiseRejection'], + skipFrames: rejection instanceof Error ? 0 : 1, + }, + ), + ); + } catch (err) { + warnFailure('failed to report an unhandled rejection', err); + } + } } diff --git a/packages/react-native/tests/selfHealingTests.spec.ts b/packages/react-native/tests/selfHealingTests.spec.ts new file mode 100644 index 000000000..e8c26d522 --- /dev/null +++ b/packages/react-native/tests/selfHealingTests.spec.ts @@ -0,0 +1,262 @@ +import { NativeModules } from 'react-native'; +import { mockStreamFileSystem } from './_mocks/fileSystem'; + +// This package's jest config replaces the react-native preset's setupFiles; the real Platform throws. +jest.mock('react-native', () => ({ + NativeModules: {}, + Platform: { + OS: 'ios', + constants: {}, + select: (options: Record) => (options.ios !== undefined ? options.ios : options.default), + }, +})); + +jest.mock('../src/common/platformHelper', () => ({ + version: () => '0.81.6', +})); + +jest.mock('promise/setimmediate/rejection-tracking', () => ({ + enable: jest.fn(), +})); + +const nativeMock = { + initialize: jest.fn(), + useAttributes: jest.fn(), + useAttachments: jest.fn(), + crash: jest.fn(), +}; + +// CrashReporter reads BacktraceReactNative into a static field at module load. +NativeModules.BacktraceReactNative = nativeMock; +(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true; +(globalThis as unknown as { ErrorUtils: unknown }).ErrorUtils = { + getGlobalHandler: () => jest.fn(), + setGlobalHandler: jest.fn(), +}; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { BacktraceClient } = require('../src/BacktraceClient'); +const { CrashReporter } = require('../src/crashReporter/CrashReporter'); +const { UnhandledExceptionHandler } = require('../src/handlers/UnhandledExceptionHandler'); +const { WebRequestEventSubscriber } = require('../src/breadcrumbs/events/WebRequestEventSubscriber'); +const { ErrorBoundary } = require('../src/ErrorBoundary'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const URL = 'https://submit.backtrace.io/universe/token/json'; + +function warnings(warnSpy: jest.SpyInstance): string { + return warnSpy.mock.calls.map((call) => String(call[0])).join('\n'); +} + +describe('Self healing', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + (CrashReporter as unknown as { initialized: boolean }).initialized = false; + (BacktraceClient as unknown as { _instance?: unknown })._instance = undefined; + }); + + afterEach(() => { + BacktraceClient.instance?.dispose(); + warnSpy.mockRestore(); + }); + + describe('initialize', () => { + it('Should return an enabled client and fall back when the application attributes are unavailable', () => { + const client = BacktraceClient.initialize({ + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + }); + + expect(client.enabled).toBe(true); + expect(client.attributes['application']).toBe('unknown'); + expect(client.attributes['application.version']).toBe('unknown'); + expect(warnings(warnSpy)).toContain('application and application.version not found'); + }); + + it('Should keep user-provided application attributes over the fallback', () => { + const client = BacktraceClient.initialize({ + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + userAttributes: { application: 'my-app', 'application.version': '2.0.0' }, + }); + + expect(client.attributes['application']).toBe('my-app'); + expect(client.attributes['application.version']).toBe('2.0.0'); + expect(warnings(warnSpy)).not.toContain('not found'); + }); + + it('Should run without the file system when the native storage modules are missing', () => { + const client = BacktraceClient.initialize({ + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + database: { enable: true, captureNativeCrashes: true, path: '/backtrace' }, + }); + + expect(client.enabled).toBe(true); + expect(client.database).toBeUndefined(); + expect(warnings(warnSpy)).toContain('native storage modules are missing'); + }); + + it('Should disable the database instead of throwing when database.path is missing', () => { + const client = BacktraceClient.initialize({ + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + database: { enable: true, createDatabaseDirectory: true } as never, + }); + + expect(client.enabled).toBe(true); + expect(client.database).toBeUndefined(); + expect(warnings(warnSpy)).toContain('database.path is missing'); + expect(warnings(warnSpy)).not.toContain('error reporting is off'); + }); + + it('Should return a disabled client instead of throwing when the client cannot be created', async () => { + const client = BacktraceClient.initialize({ url: undefined as unknown as string }); + + expect(client).toBeDefined(); + expect(client.enabled).toBe(false); + expect((await client.send('message')).status).not.toBe('Ok'); + expect(warnings(warnSpy)).toContain('error reporting is off'); + }); + + it('Should stay enabled when the native crash reporter throws during initialization', async () => { + nativeMock.initialize.mockImplementation(() => { + throw new Error('native init failed'); + }); + + const client = new BacktraceClient({ + options: { + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + database: { enable: true, captureNativeCrashes: true, path: '/backtrace' }, + userAttributes: { application: 'app', 'application.version': '1.0.0' }, + }, + fileSystem: mockStreamFileSystem(), + }); + client.initialize(); + + expect(client.enabled).toBe(true); + expect(warnings(warnSpy)).toContain('native crash reporting is off'); + client.dispose(); + }); + + it('Should report every native disable reason', () => { + nativeMock.initialize.mockReturnValue(false); + + const client = new BacktraceClient({ + options: { + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + database: { enable: true, captureNativeCrashes: true, path: '/backtrace' }, + userAttributes: { application: 'app', 'application.version': '1.0.0' }, + }, + fileSystem: mockStreamFileSystem(), + }); + client.initialize(); + + expect(warnings(warnSpy)).toContain('the native crash handler did not start'); + client.dispose(); + }); + + it('Should allow a fresh client after dispose', () => { + const first = BacktraceClient.initialize({ + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + }); + first.dispose(); + + const second = BacktraceClient.initialize({ + url: URL, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + }); + expect(second).not.toBe(first); + expect(second.enabled).toBe(true); + }); + }); + + describe('global error handler', () => { + let registeredHandler: (error: Error, fatal?: boolean) => void; + let previousGlobalHandler: jest.Mock; + + beforeEach(() => { + previousGlobalHandler = jest.fn(); + (globalThis as unknown as { ErrorUtils: unknown }).ErrorUtils = { + getGlobalHandler: () => previousGlobalHandler, + setGlobalHandler: (fn: typeof registeredHandler) => { + registeredHandler = fn; + }, + }; + }); + + it('Should call the previous handler even when reporting throws', () => { + const handler = new UnhandledExceptionHandler(); + handler.captureManagedErrors({ + send: () => { + throw new Error('send failed'); + }, + }); + + const error = new Error('app error'); + expect(() => registeredHandler(error, true)).not.toThrow(); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + expect(warnings(warnSpy)).toContain('failed to report an unhandled error'); + }); + + it('Should call the previous handler after the handler is disposed', () => { + const send = jest.fn(); + const handler = new UnhandledExceptionHandler(); + handler.captureManagedErrors({ send }); + handler.dispose(); + + const error = new Error('app error'); + registeredHandler(error, false); + + expect(send).not.toHaveBeenCalled(); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, false); + }); + }); + + describe('web request breadcrumbs', () => { + it('Should preserve the async flag passed to XMLHttpRequest.open', () => { + const open = jest.fn(); + class FakeXhr { + public static DONE = 4; + } + (FakeXhr.prototype as unknown as { open: jest.Mock }).open = open; + (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = FakeXhr; + (globalThis as unknown as { window: unknown }).window = { fetch: jest.fn() }; + + const subscriber = new WebRequestEventSubscriber(); + subscriber.start({ breadcrumbsType: 0xffff, addBreadcrumb: jest.fn() }); + + const xhr = new FakeXhr(); + FakeXhr.prototype.open.call(xhr, 'GET', 'https://example.com', false); + FakeXhr.prototype.open.call(xhr, 'GET', 'https://example.com'); + + expect(open.mock.calls[0][2]).toBe(false); + expect(open.mock.calls[1][2]).toBe(true); + subscriber.dispose(); + }); + }); + + describe('error boundary', () => { + it('Should not throw when the client is not initialized', () => { + expect(() => new ErrorBoundary({ children: null })).not.toThrow(); + expect(warnings(warnSpy)).toContain('ErrorBoundary reports nothing'); + + const boundary = new ErrorBoundary({ children: null }); + expect(() => boundary.componentDidCatch(new Error('render'), { componentStack: '' })).not.toThrow(); + }); + }); +}); diff --git a/packages/sdk-core/src/BacktraceCoreClient.ts b/packages/sdk-core/src/BacktraceCoreClient.ts index 2416b1a81..c45810379 100644 --- a/packages/sdk-core/src/BacktraceCoreClient.ts +++ b/packages/sdk-core/src/BacktraceCoreClient.ts @@ -1,5 +1,6 @@ import { CoreClientSetup } from './builder/CoreClientSetup.js'; import { Events } from './common/Events.js'; +import { failureType, warnFailure } from './common/failureLog.js'; import { ClientEvents } from './events/ClientEvents.js'; import { BacktraceAttachment, @@ -309,41 +310,51 @@ export abstract class BacktraceCoreClient< return Promise.resolve(BacktraceReportSubmissionResult.OnLimitReached('Client')); } - // If data is BacktraceReport, we know that the second argument should be only AbortSignal - const reportAttributes = !this.isReport(data) - ? (reportAttributesOrAbortSignal as Record) - : undefined; + try { + // If data is BacktraceReport, we know that the second argument should be only AbortSignal + const reportAttributes = !this.isReport(data) + ? (reportAttributesOrAbortSignal as Record) + : undefined; - // If data is BacktraceReport, we know that the second argument should be only AbortSignal - abortSignal = !this.isReport(data) ? abortSignal : (reportAttributesOrAbortSignal as AbortSignal); + // If data is BacktraceReport, we know that the second argument should be only AbortSignal + abortSignal = !this.isReport(data) ? abortSignal : (reportAttributesOrAbortSignal as AbortSignal); - const report = this.isReport(data) - ? data - : new BacktraceReport(data, reportAttributes, [], { - skipFrames: this.skipFrameOnMessage(data), - }); + const report = this.isReport(data) + ? data + : new BacktraceReport(data, reportAttributes, [], { + skipFrames: this.skipFrameOnMessage(data), + }); - this.emit('before-skip', report); + this.emit('before-skip', report); - if (this.options.skipReport && this.options.skipReport(report)) { - return Promise.resolve(BacktraceReportSubmissionResult.ReportSkipped()); - } + if (this.options.skipReport && this.options.skipReport(report)) { + return Promise.resolve(BacktraceReportSubmissionResult.ReportSkipped()); + } - const backtraceData = this.generateSubmissionData(report); - if (!backtraceData) { - return Promise.resolve(BacktraceReportSubmissionResult.ReportSkipped()); - } + const backtraceData = this.generateSubmissionData(report); + if (!backtraceData) { + return Promise.resolve(BacktraceReportSubmissionResult.ReportSkipped()); + } - const submissionAttachments = this.generateSubmissionAttachments(report, reportAttachments); + const submissionAttachments = this.generateSubmissionAttachments(report, reportAttachments); - this.emit('before-send', report, backtraceData, submissionAttachments); + this.emit('before-send', report, backtraceData, submissionAttachments); + + return this._reportSubmission + .send(backtraceData, submissionAttachments, abortSignal) + .then((submissionResult) => { + this.emit('after-send', report, backtraceData, submissionAttachments, submissionResult); + return submissionResult; + }) + .catch((err) => this.onSendFailure(err)); + } catch (err) { + return Promise.resolve(this.onSendFailure(err)); + } + } - return this._reportSubmission - .send(backtraceData, submissionAttachments, abortSignal) - .then((submissionResult) => { - this.emit('after-send', report, backtraceData, submissionAttachments, submissionResult); - return submissionResult; - }); + private onSendFailure(err: unknown): BacktraceReportSubmissionResult { + warnFailure('failed to send a report', err); + return BacktraceReportSubmissionResult.OnUnknownError(`SDK failure: ${failureType(err)}`); } /** diff --git a/packages/sdk-core/src/common/failureLog.ts b/packages/sdk-core/src/common/failureLog.ts new file mode 100644 index 000000000..b17f7dad7 --- /dev/null +++ b/packages/sdk-core/src/common/failureLog.ts @@ -0,0 +1,11 @@ +// Error messages can carry submission URLs, tokens or file paths. +export function failureType(error: unknown): string { + if (error instanceof Error) { + return error.name || 'Error'; + } + return error == null ? 'unknown' : typeof error; +} + +export function warnFailure(message: string, error?: unknown): void { + console.warn(error === undefined ? `Backtrace: ${message}` : `Backtrace: ${message} (${failureType(error)})`); +} diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index c98ec8bed..fab8929b7 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -4,6 +4,7 @@ export * from './builder/BacktraceCoreClientBuilder.js'; export * from './builder/CoreClientSetup.js'; export * from './builder/SdkOptions.js'; export { anySignal } from './common/AbortController.js'; +export * from './common/failureLog.js'; export * from './common/IdGenerator.js'; export * from './common/jsonEscaper.js'; export * from './common/TimeHelper.js'; diff --git a/packages/sdk-core/src/modules/attribute/AttributeManager.ts b/packages/sdk-core/src/modules/attribute/AttributeManager.ts index badcf0b90..b179b76b2 100644 --- a/packages/sdk-core/src/modules/attribute/AttributeManager.ts +++ b/packages/sdk-core/src/modules/attribute/AttributeManager.ts @@ -1,4 +1,5 @@ import { Events } from '../../common/Events.js'; +import { warnFailure } from '../../common/failureLog.js'; import { AttributeEvents } from '../../events/AttributeEvents.js'; import { ReportData } from '../../model/report/ReportData.js'; import { BacktraceAttributeProvider } from './BacktraceAttributeProvider.js'; @@ -8,6 +9,7 @@ export class AttributeManager { public readonly attributeEvents: Events; private readonly _attributeProviders: BacktraceAttributeProvider[] = []; + private readonly _failedProviders = new WeakSet(); constructor(providers: BacktraceAttributeProvider[]) { this.attributeEvents = new Events(); @@ -38,7 +40,7 @@ export class AttributeManager { this._attributeProviders.push(attributeProvider); return; } else { - const attributes = attributeProvider.get(); + const attributes = this.resolve(attributeProvider); this._attributeProviders.push({ type: 'scoped', get: () => attributes, @@ -47,6 +49,18 @@ export class AttributeManager { } } + private resolve(attributeProvider: BacktraceAttributeProvider): Record { + try { + return attributeProvider.get() ?? {}; + } catch (err) { + if (!this._failedProviders.has(attributeProvider)) { + this._failedProviders.add(attributeProvider); + warnFailure('skipped an attribute provider that threw', err); + } + return {}; + } + } + /** * Gets client attributes * @returns Report attribute - client attributes and annotations @@ -61,7 +75,7 @@ export class AttributeManager { if (attributeType && attributeProvider.type != attributeType) { continue; } - const providerResult = ReportDataBuilder.build(attributeProvider.get()); + const providerResult = ReportDataBuilder.build(this.resolve(attributeProvider)); result.attributes = { ...result.attributes, diff --git a/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts b/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts index 0a99034e7..6b5c3bfaf 100644 --- a/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts +++ b/packages/sdk-core/src/modules/breadcrumbs/BreadcrumbsManager.ts @@ -1,3 +1,4 @@ +import { warnFailure } from '../../common/failureLog.js'; import { jsonEscaper } from '../../common/jsonEscaper.js'; import { jsonSize } from '../../common/jsonSize.js'; import { limitObjectDepth } from '../../common/limitObjectDepth.js'; @@ -44,6 +45,8 @@ export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule * Determines if the breadcrumb manager is enabled. */ private _enabled = false; + private _adding = false; + private _failureLogged = false; private readonly _limits: BreadcrumbLimits; private readonly _eventSubscribers: BreadcrumbsEventSubscriber[] = [new ConsoleEventSubscriber()]; @@ -154,10 +157,31 @@ export class BreadcrumbsManager implements BacktraceBreadcrumbs, BacktraceModule type: BreadcrumbType, attributes?: Record | undefined, ): boolean { - if (!this._enabled) { + // The patched console calls this; _adding stops an interceptor that logs from recursing. + if (!this._enabled || this._adding) { return false; } + this._adding = true; + try { + return this.createBreadcrumb(message, level, type, attributes); + } catch (err) { + if (!this._failureLogged) { + this._failureLogged = true; + warnFailure('failed to record a breadcrumb', err); + } + return false; + } finally { + this._adding = false; + } + } + + private createBreadcrumb( + message: string, + level: BreadcrumbLogLevel, + type: BreadcrumbType, + attributes?: Record | undefined, + ): boolean { let rawBreadcrumb: RawBreadcrumb = { message: this.prepareBreadcrumbMessage(message), level, diff --git a/packages/sdk-core/src/modules/database/BacktraceDatabase.ts b/packages/sdk-core/src/modules/database/BacktraceDatabase.ts index 2c7fb15ad..6b1778be1 100644 --- a/packages/sdk-core/src/modules/database/BacktraceDatabase.ts +++ b/packages/sdk-core/src/modules/database/BacktraceDatabase.ts @@ -1,5 +1,6 @@ import { anySignal, createAbortController } from '../../common/AbortController.js'; import { Events } from '../../common/Events.js'; +import { warnFailure } from '../../common/failureLog.js'; import { IdGenerator } from '../../common/IdGenerator.js'; import { unrefInterval } from '../../common/intervalHelper.js'; import { TimeHelper } from '../../common/TimeHelper.js'; @@ -81,6 +82,7 @@ export class BacktraceDatabase extends Events implement .then(() => { this.setupDatabaseAutoSend(); }) + .catch((err) => warnFailure('failed to load stored reports', err)) .finally(() => lockId && this._sessionFiles?.unlockPreviousSessions(lockId)); this._enabled = true; diff --git a/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts b/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts index 2a30bc8e7..edbb99f0c 100644 --- a/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts +++ b/packages/sdk-core/src/modules/metrics/BacktraceMetrics.ts @@ -1,5 +1,6 @@ import { createAbortController } from '../../common/AbortController.js'; import { AbortError } from '../../common/AbortError.js'; +import { warnFailure } from '../../common/failureLog.js'; import { unrefInterval } from '../../common/intervalHelper.js'; import { TimeHelper } from '../../common/TimeHelper.js'; import { BacktraceMetricsOptions } from '../../model/configuration/BacktraceConfiguration.js'; @@ -146,10 +147,10 @@ export class BacktraceMetrics implements BacktraceModule { await fn(); return true; } catch (err) { - if (err instanceof AbortError) { - return false; + if (!(err instanceof AbortError)) { + warnFailure('failed to send metrics', err); } - throw err; + return false; } } } diff --git a/packages/sdk-core/tests/client/failureContainmentTests.spec.ts b/packages/sdk-core/tests/client/failureContainmentTests.spec.ts new file mode 100644 index 000000000..08b330331 --- /dev/null +++ b/packages/sdk-core/tests/client/failureContainmentTests.spec.ts @@ -0,0 +1,129 @@ +import { BreadcrumbsStorage } from '../../src/index.js'; +import { AttributeManager } from '../../src/modules/attribute/AttributeManager.js'; +import { BreadcrumbsManager } from '../../src/modules/breadcrumbs/BreadcrumbsManager.js'; +import { BacktraceTestClient } from '../mocks/BacktraceTestClient.js'; + +describe('Failure containment', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + jest.clearAllMocks(); + }); + + describe('attribute providers', () => { + it('Should skip a throwing scoped provider and keep the others', () => { + const manager = new AttributeManager([ + { + type: 'scoped', + get: () => { + throw new Error('secret-token-in-message'); + }, + }, + { type: 'scoped', get: () => ({ kept: 'yes' }) }, + ]); + + expect(manager.get().attributes).toEqual({ kept: 'yes' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('skipped an attribute provider'); + expect(warnSpy.mock.calls[0][0]).not.toContain('secret-token-in-message'); + }); + + it('Should skip a throwing dynamic provider on every resolution and warn once', () => { + const manager = new AttributeManager([ + { + type: 'dynamic', + get: () => { + throw new Error('boom'); + }, + }, + { type: 'dynamic', get: () => ({ kept: 'yes' }) }, + ]); + + expect(manager.get().attributes).toEqual({ kept: 'yes' }); + expect(manager.get().attributes).toEqual({ kept: 'yes' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('breadcrumbs', () => { + function throwingStorage(): BreadcrumbsStorage { + return { + lastBreadcrumbId: 0, + add: () => { + throw new Error('disk full'); + }, + getAttachments: () => [], + } as unknown as BreadcrumbsStorage; + } + + it('Should return false instead of throwing when the storage throws', () => { + const manager = new BreadcrumbsManager(undefined, { storage: throwingStorage }); + manager.initialize(); + + expect(manager.info('first')).toBe(false); + expect(manager.info('second')).toBe(false); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('failed to record a breadcrumb'); + manager.dispose(); + }); + + it('Should return false instead of throwing when the interceptor throws', () => { + const manager = new BreadcrumbsManager({ + intercept: () => { + throw new Error('interceptor bug'); + }, + }); + manager.initialize(); + + expect(manager.info('test')).toBe(false); + manager.dispose(); + }); + + it('Should keep the patched console usable when the storage throws', () => { + const manager = new BreadcrumbsManager(undefined, { storage: throwingStorage }); + manager.initialize(); + + expect(() => console.log('app log')).not.toThrow(); + expect(() => console.error('app error')).not.toThrow(); + manager.dispose(); + }); + }); + + describe('send', () => { + it('Should resolve with an Unknown status instead of throwing when beforeSend throws', async () => { + const client = BacktraceTestClient.buildFakeClient({ + beforeSend: () => { + throw new Error('boom'); + }, + }); + + const result = await client.send(new Error('test')); + expect(result.status).toBe('Unknown'); + expect(warnSpy.mock.calls[0][0]).toContain('failed to send a report'); + }); + + it('Should resolve instead of rejecting when an after-send listener throws', async () => { + const client = BacktraceTestClient.buildFakeClient(); + client.on('after-send', () => { + throw new Error('boom'); + }); + + await expect(client.send(new Error('test'))).resolves.toBeDefined(); + }); + + it('Should still send the report when a dynamic attribute provider throws', async () => { + const client = BacktraceTestClient.buildFakeClient(); + client.addAttribute(() => { + throw new Error('boom'); + }); + + const result = await client.send(new Error('test')); + expect(result.status).toBe('Ok'); + }); + }); +});