diff --git a/packages/react-native/.eslintrc.json b/packages/react-native/.eslintrc.json new file mode 100644 index 00000000..a8e317b2 --- /dev/null +++ b/packages/react-native/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "plugins": ["import"], + "settings": { + "import/ignore": ["node_modules"], + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + }, + "rules": { + "import/no-cycle": ["error", { "ignoreExternal": true }] + } +} diff --git a/packages/react-native/android/build.gradle b/packages/react-native/android/build.gradle index 12ddb8a0..6c4f14d9 100644 --- a/packages/react-native/android/build.gradle +++ b/packages/react-native/android/build.gradle @@ -10,7 +10,11 @@ buildscript { } def isNewArchitectureEnabled() { - return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" + // Read the property from this project rather than the root project: + // since React Native 0.82 the New Architecture is always on and the React Native Gradle plugin sets `newArchEnabled` on every subproject, while the app's gradle.properties no longer has to define it. + return ["newArchEnabled", "react.newArchEnabled"].any { name -> + project.hasProperty(name) && project.getProperty(name).toString().equalsIgnoreCase("true") + } } apply plugin: "com.android.library" @@ -38,7 +42,9 @@ def supportsNamespace() { android { if (supportsNamespace()) { - namespace "com.backtrace.reactnative" + // Must match the Java package of ReactNativePackage: Expo's autolinking derives the + // `import .ReactNativePackage;` line in PackageList.java from the namespace. + namespace "backtraceio.library" sourceSets { main { @@ -89,6 +95,7 @@ if (isNewArchitectureEnabled()) { react { jsRootDir = file("../src/") libraryName = "ReactNative" + // Java package for codegen output only; intentionally independent of the library namespace above. codegenJavaPackageName = "com.backtrace.reactnative" } } diff --git a/packages/react-native/android/gradle.properties b/packages/react-native/android/gradle.properties index ccd5c2df..85e2941f 100644 --- a/packages/react-native/android/gradle.properties +++ b/packages/react-native/android/gradle.properties @@ -1,3 +1,4 @@ +# Do not set newArchEnabled / react.newArchEnabled here: a project-directory gradle.properties overrides the consuming app's root gradle.properties for this subproject. ReactNative_minSdkVersion=16 ReactNative_targetSdkVersion=35 ReactNative_compileSdkVersion=35 diff --git a/packages/react-native/backtrace-react-native.podspec b/packages/react-native/backtrace-react-native.podspec index 8668928e..24b56e6b 100644 --- a/packages/react-native/backtrace-react-native.podspec +++ b/packages/react-native/backtrace-react-native.podspec @@ -15,7 +15,11 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/backtrace-labs/backtrace-javascript.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm}" - + + # PLCrashReporter is a static xcframework, this pod has to be built as a static framework + # when the app uses `use_frameworks!` with dynamic linkage (CocoaPods rejects the install otherwise). + s.static_framework = true + s.dependency "PLCrashReporter" # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index 1ca6bdf5..03fd6aaa 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -1,5 +1,6 @@ import { BacktraceCoreClient, + BacktraceCoreClientBuilder, BreadcrumbsManager, SingleSessionProvider, SubmissionUrlInformation, @@ -15,10 +16,15 @@ import { NativeModules, Platform } from 'react-native'; import { AnrException } from './anr/AnrException'; import { AnrReporter } from './anr/AnrReporter'; import { AnrWatchdogHandler } from './anr/AnrWatchdogHandler'; +import { NativeAttributeProvider } from './attributes/NativeAttributeProvider'; +import { ReactNativeAttributeProvider } from './attributes/ReactNativeAttributeProvider'; import { BacktraceAnrType, type BacktraceConfiguration } from './BacktraceConfiguration'; +import { AppStateBreadcrumbSubscriber } from './breadcrumbs/events/AppStateBreadcrumbSubscriber'; +import { DimensionChangeBreadcrumbSubscriber } from './breadcrumbs/events/DimensionChangeBreadcrumbSubscriber'; +import { WebRequestEventSubscriber } from './breadcrumbs/events/WebRequestEventSubscriber'; import { FileBreadcrumbsStorage } from './breadcrumbs/FileBreadcrumbsStorage'; -import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; import type { BacktraceClientSetup } from './builder/BacktraceClientSetup'; +import { DebuggerHelper } from './common/DebuggerHelper'; import { version } from './common/platformHelper'; import { version as agentVersion } from '../package.json'; import { CrashReporter } from './crashReporter/CrashReporter'; @@ -28,6 +34,7 @@ import { type ExceptionHandler } from './handlers/ExceptionHandler'; import { ReactNativeRequestHandler } from './ReactNativeRequestHandler'; import { ReactStackTraceConverter } from './ReactStackTraceConverter'; import { type FileSystem } from './storage/FileSystem'; +import { ReactNativeFileSystem } from './storage/ReactNativeFileSystem'; // Must match the private attribute name BreadcrumbsManager sets on JS reports. const BREADCRUMB_ATTRIBUTE_NAME = 'breadcrumbs.lastId'; @@ -316,3 +323,75 @@ export class BacktraceClient extends BacktraceCoreClient return initialized ? crashReporter : undefined; } } + +/** + * Builder for {@link BacktraceClient}; obtain one with `BacktraceClient.builder(options)`. + */ +// Implementation note: +// defined in the same module as `BacktraceClient` on purpose. `BacktraceClient.builder()` creates the builder and `BacktraceClientBuilder.build()` creates the client, splitting them into two modules creates a circular import. +// The browser and node packages keep that cycle harmlessly because rollup bundles them into a single scope; +// this package ships per-file modules to Metro, and Metro's import/export transform (`experimentalImportSupport`) captures imported bindings when a module is evaluated, which leaves one side of the cycle `undefined`. +export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { + constructor(clientSetup: BacktraceClientSetup) { + super(clientSetup); + + this.addAttributeProvider(new ReactNativeAttributeProvider()); + if (!DebuggerHelper.isNativeBridgeEnabled()) { + return; + } + + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + return; + } + + const attributeProviders = Platform.select({ + ios: [ + new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceMemoryUsageAttributeProvider', 'dynamic'), + new NativeAttributeProvider('BacktraceCpuAttributeProvider', 'dynamic'), + ], + android: [ + new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), + new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), + new NativeAttributeProvider('MemoryInformationAttributeProvider', 'dynamic'), + new NativeAttributeProvider('ProcessAttributeProvider', 'dynamic'), + ], + default: [], + }); + + for (const provider of attributeProviders) { + this.addAttributeProvider(provider); + } + + const fileSystem = this.createFileSystem(); + if (fileSystem) { + this.useFileSystem(fileSystem); + } + this.useBreadcrumbSubscriber(new AppStateBreadcrumbSubscriber()); + this.useBreadcrumbSubscriber(new DimensionChangeBreadcrumbSubscriber()); + this.useBreadcrumbSubscriber(new WebRequestEventSubscriber()); + } + + public useFileSystem(fileSystem: ReactNativeFileSystem): this { + super.useFileSystem(fileSystem); + return this; + } + + private createFileSystem(): ReactNativeFileSystem | undefined { + try { + return new ReactNativeFileSystem(); + } catch (err) { + warnFailure('native storage modules are missing, the database and native crash reporting are off', err); + return undefined; + } + } + + public build(): BacktraceClient { + const instance = new BacktraceClient(this.clientSetup); + instance.initialize(); + return instance; + } +} diff --git a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts index 2b094357..a19a67d2 100644 --- a/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts +++ b/packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts @@ -12,7 +12,7 @@ import { type RawBreadcrumb, } from '@backtrace/sdk-core'; import { WritableStream } from 'web-streams-polyfill'; -import { BacktraceFileAttachment } from '..'; +import { BacktraceFileAttachment } from '../attachment/BacktraceFileAttachment'; import { type FileSystem } from '../storage'; import { ChunkifierSink, type ChunkSplitterFactory } from '../storage/Chunkifier'; import { combinedChunkSplitter } from '../storage/combinedChunkSplitter'; diff --git a/packages/react-native/src/builder/BacktraceClientBuilder.ts b/packages/react-native/src/builder/BacktraceClientBuilder.ts index c1ba1be4..0ec98e5c 100644 --- a/packages/react-native/src/builder/BacktraceClientBuilder.ts +++ b/packages/react-native/src/builder/BacktraceClientBuilder.ts @@ -1,76 +1,2 @@ -import { BacktraceCoreClientBuilder, warnFailure } from '@backtrace/sdk-core'; -import { Platform } from 'react-native'; -import { NativeAttributeProvider } from '../attributes/NativeAttributeProvider'; -import { ReactNativeAttributeProvider } from '../attributes/ReactNativeAttributeProvider'; -import { BacktraceClient } from '../BacktraceClient'; -import { AppStateBreadcrumbSubscriber } from '../breadcrumbs/events/AppStateBreadcrumbSubscriber'; -import { DimensionChangeBreadcrumbSubscriber } from '../breadcrumbs/events/DimensionChangeBreadcrumbSubscriber'; -import { WebRequestEventSubscriber } from '../breadcrumbs/events/WebRequestEventSubscriber'; -import { DebuggerHelper } from '../common/DebuggerHelper'; -import { ReactNativeFileSystem } from '../storage'; -import type { BacktraceClientSetup } from './BacktraceClientSetup'; - -export class BacktraceClientBuilder extends BacktraceCoreClientBuilder { - constructor(clientSetup: BacktraceClientSetup) { - super(clientSetup); - - this.addAttributeProvider(new ReactNativeAttributeProvider()); - if (!DebuggerHelper.isNativeBridgeEnabled()) { - return; - } - - if (Platform.OS !== 'android' && Platform.OS !== 'ios') { - return; - } - - const attributeProviders = Platform.select({ - ios: [ - new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceMemoryUsageAttributeProvider', 'dynamic'), - new NativeAttributeProvider('BacktraceCpuAttributeProvider', 'dynamic'), - ], - android: [ - new NativeAttributeProvider('BacktraceApplicationAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceDeviceAttributeProvider', 'scoped'), - new NativeAttributeProvider('BacktraceSystemAttributeProvider', 'scoped'), - new NativeAttributeProvider('MemoryInformationAttributeProvider', 'dynamic'), - new NativeAttributeProvider('ProcessAttributeProvider', 'dynamic'), - ], - default: [], - }); - - for (const provider of attributeProviders) { - this.addAttributeProvider(provider); - } - - const fileSystem = this.createFileSystem(); - if (fileSystem) { - this.useFileSystem(fileSystem); - } - this.useBreadcrumbSubscriber(new AppStateBreadcrumbSubscriber()); - this.useBreadcrumbSubscriber(new DimensionChangeBreadcrumbSubscriber()); - this.useBreadcrumbSubscriber(new WebRequestEventSubscriber()); - } - - public useFileSystem(fileSystem: ReactNativeFileSystem): this { - super.useFileSystem(fileSystem); - return this; - } - - private createFileSystem(): ReactNativeFileSystem | undefined { - try { - return new ReactNativeFileSystem(); - } catch (err) { - warnFailure('native storage modules are missing, the database and native crash reporting are off', err); - return undefined; - } - } - - public build(): BacktraceClient { - const instance = new BacktraceClient(this.clientSetup); - instance.initialize(); - return instance; - } -} +// `BacktraceClientBuilder` lives next to `BacktraceClient` (see the note there); this module keeps the import path. +export { BacktraceClientBuilder } from '../BacktraceClient'; diff --git a/packages/react-native/src/handlers/ExceptionHandler.ts b/packages/react-native/src/handlers/ExceptionHandler.ts index 6acd0909..a46ec1ab 100644 --- a/packages/react-native/src/handlers/ExceptionHandler.ts +++ b/packages/react-native/src/handlers/ExceptionHandler.ts @@ -1,4 +1,4 @@ -import { BacktraceClient } from '../BacktraceClient'; +import type { BacktraceClient } from '../BacktraceClient'; export interface ExceptionHandler { captureUnhandledPromiseRejections(client: BacktraceClient): void; diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index 58e57505..539d636b 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -1,5 +1,5 @@ import { BacktraceReport, warnFailure } from '@backtrace/sdk-core'; -import { BacktraceClient } from '../BacktraceClient'; +import type { BacktraceClient } from '../BacktraceClient'; import { hermes } from '../common/hermesHelper'; import { CrashReporter } from '../crashReporter/CrashReporter'; import { type ExceptionHandler } from './ExceptionHandler'; diff --git a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts index 76a6cbc8..f2beeb4c 100644 --- a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts @@ -1,6 +1,6 @@ import { BacktraceReport } from '@backtrace/sdk-core'; import { NativeModules } from 'react-native'; -import { BacktraceClient } from '../../BacktraceClient'; +import type { BacktraceClient } from '../../BacktraceClient'; import { DebuggerHelper } from '../../common/DebuggerHelper'; import { AndroidStackTraceConverter } from '../../converters/AndroidStackTraceConverter'; import { UnhandledExceptionHandler } from '../UnhandledExceptionHandler'; diff --git a/packages/react-native/tests/clientInitializationTests.spec.ts b/packages/react-native/tests/clientInitializationTests.spec.ts new file mode 100644 index 00000000..dfcdccbb --- /dev/null +++ b/packages/react-native/tests/clientInitializationTests.spec.ts @@ -0,0 +1,79 @@ +import { NativeModules } from 'react-native'; +import { promisify } from 'util'; +import { mockStreamFileSystem } from './_mocks/fileSystem'; + +// This package's jest config replaces the react-native preset's setupFiles, so the real Platform throws. +jest.mock('react-native', () => ({ + NativeModules: {}, + Platform: { + OS: 'ios', + select: (options: Record) => (options.ios !== undefined ? options.ios : options.default), + }, +})); + +jest.mock('../src/common/platformHelper', () => ({ + version: () => '0.81.6', +})); + +// Keep the builder away from the native modules: +// with the bridge reported as unavailable it registers neither native attribute providers nor the native file system, and the test injects a mocked file system instead. +NativeModules.UIManager = {}; +NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' }; + +// Loaded through the package entry point on purpose: this is the path applications take, +// it's where the circular imports between the client, the builder and the breadcrumbs storage used to surface. +/* eslint-disable @typescript-eslint/no-var-requires */ +const { BacktraceClient, BacktraceClientBuilder, BacktraceFileAttachment } = require('../src'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const nextTick = promisify(process.nextTick); + +const options = { + url: 'https://submit.backtrace.io/universe/token/json', + database: { enable: true, path: '/backtrace' }, + metrics: { enable: false }, + breadcrumbs: { enable: true, maximumBreadcrumbs: 4 }, + captureUnhandledErrors: false, + captureUnhandledPromiseRejections: false, + userAttributes: { application: 'clientInitialization', 'application.version': '1.0.0' }, +}; + +describe('BacktraceClient initialization through the package entry point', () => { + afterEach(() => { + BacktraceClient.instance?.dispose(); + // initialize() stores the singleton on BacktraceClient itself, which dispose() does not clear. + (BacktraceClient as { _instance?: unknown })._instance = undefined; + }); + + it('Should create the builder from the client', () => { + expect(BacktraceClient.builder(options)).toBeInstanceOf(BacktraceClientBuilder); + }); + + it('Should initialize the client through the builder', () => { + const client = BacktraceClient.initialize(options, (builder: typeof BacktraceClientBuilder.prototype) => { + expect(builder).toBeInstanceOf(BacktraceClientBuilder); + builder.useFileSystem(mockStreamFileSystem()); + }); + + expect(client).toBeInstanceOf(BacktraceClient); + expect(BacktraceClient.instance).toBe(client); + }); + + it('Should expose breadcrumb files as file attachments', async () => { + const client = BacktraceClient.initialize(options, (builder: typeof BacktraceClientBuilder.prototype) => + builder.useFileSystem(mockStreamFileSystem()), + ); + + // Enough breadcrumbs to rotate the breadcrumb files at least once. + for (let i = 0; i < 20; i++) { + client.breadcrumbs?.info(`breadcrumb-${i}`); + await nextTick(); + } + for (let i = 0; i < 10; i++) { + await nextTick(); + } + + const attachments = client.attachments.filter((attachment) => attachment instanceof BacktraceFileAttachment); + expect(attachments.length).toBeGreaterThan(0); + }); +});