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
14 changes: 14 additions & 0 deletions packages/react-native/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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 }]
}
}
11 changes: 9 additions & 2 deletions packages/react-native/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <namespace>.ReactNativePackage;` line in PackageList.java from the namespace.
namespace "backtraceio.library"

sourceSets {
main {
Expand Down Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions packages/react-native/android/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/react-native/backtrace-react-native.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
81 changes: 80 additions & 1 deletion packages/react-native/src/BacktraceClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
BacktraceCoreClient,
BacktraceCoreClientBuilder,
BreadcrumbsManager,
SingleSessionProvider,
SubmissionUrlInformation,
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -316,3 +323,75 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
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<BacktraceClientSetup> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
78 changes: 2 additions & 76 deletions packages/react-native/src/builder/BacktraceClientBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<BacktraceClientSetup> {
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';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safe to delete.

2 changes: 1 addition & 1 deletion packages/react-native/src/handlers/ExceptionHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BacktraceClient } from '../BacktraceClient';
import type { BacktraceClient } from '../BacktraceClient';

export interface ExceptionHandler {
captureUnhandledPromiseRejections(client: BacktraceClient): void;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
79 changes: 79 additions & 0 deletions packages/react-native/tests/clientInitializationTests.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => (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);
});
});
Loading