Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:

- name: Build example app
working-directory: examples/sdk/reactNative/android
run: ./gradlew assembleRelease -PbtCiDebuggable -PnewArchEnabled=${{ matrix.new-arch }} -x uploadSourceMapsToBacktrace --console=plain
run: ./gradlew assembleRelease -PbtCiDebuggable -PbtCiMinify -PnewArchEnabled=${{ matrix.new-arch }} -x uploadSourceMapsToBacktrace --console=plain

- name: Verify native libraries and JNI symbols
run: bash .github/scripts/verify-jni-symbols.sh examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk
Expand Down
2 changes: 1 addition & 1 deletion examples/sdk/reactNative/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ android {
signingConfig signingConfigs.debug
// CI-only, so run-as can read the crashpad database.
debuggable project.hasProperty("btCiDebuggable")
minifyEnabled enableProguardInReleaseBuilds
minifyEnabled enableProguardInReleaseBuilds || project.hasProperty("btCiMinify")
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
Expand Down
28 changes: 28 additions & 0 deletions packages/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and easy, after which you can explore the rich set of Backtrace features.
- [Install the package](#install-the-package)
- [Integrate the SDK](#integrate-the-sdk)
- [Upload source maps](#upload-source-maps)
- [Deobfuscate ProGuard and R8 builds (Android)](#deobfuscate-proguard-and-r8-builds-android)
1. [Error Reporting Features](#error-reporting-features)
- [Attributes](#attributes)
- [File Attachments](#file-attachments)
Expand Down Expand Up @@ -85,6 +86,32 @@ your original source identifiers.

<? TBD: Link to source upload doc ?>

### Deobfuscate ProGuard and R8 builds (Android)

Minified release builds obfuscate Java class and method names. Backtrace deobfuscates unhandled Java exception and
ANR reports with the mapping file uploaded under the report's `symbolication_id`. The package ships the keep rules
the native crash reporter needs. No ProGuard rules have to be added to the app.

Generate a UUID for the build, pass it to the client, and upload that build's `mapping.txt` under the same id:

```ts
const options: BacktraceConfiguration = {
url: 'https://submit.backtrace.io/<universe>/<token>/json',
proguard: {
enable: true,
symbolicationId: '<uuid generated for this build>',
},
};
```

```
curl --data-binary @android/app/build/outputs/mapping/release/mapping.txt -X POST -H "Expect:" "https://submit.backtrace.io/<universe>/<symbol-access-token>/proguard?symbolication_id=<uuid>"
```

JavaScript reports keep using source maps and native crash reports keep using native symbols. See
[Working with ProGuard](https://docs.saucelabs.com/error-reporting/platform-integrations/android/proguard-deobfuscation/)
for the full flow.

## Error Reporting Features

### Attributes
Expand Down Expand Up @@ -430,6 +457,7 @@ The following options are available for the BacktraceClientOptions passed when i
| `metrics` | BacktraceMetricsOptions | See [Backtrace Stability Metrics](#application-stability-metrics) | | <ul><li>- [ ] </li></ul> |
| `breadcrumbs` | BacktraceBreadcrumbsSettings | See [Backtrace Breadcrumbs](#breadcrumbs) | | <ul><li>- [ ] </li></ul> |
| `database` | BacktraceDatabaseSettings | See [Backtrace Database](#offline-database-support) | | <ul><li>- [ ] </li></ul> |
| `proguard` | BacktraceProguardConfiguration | See [Deobfuscate ProGuard and R8 builds](#deobfuscate-proguard-and-r8-builds-android) | | <ul><li>- [ ] </li></ul> |

### Manually send an error

Expand Down
1 change: 1 addition & 0 deletions packages/react-native/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ android {
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-keep class backtraceio.** { *; }
26 changes: 26 additions & 0 deletions packages/react-native/src/BacktraceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import {
V8StackTraceConverter,
VariableDebugIdMapProvider,
type AttributeType,
type BacktraceData,
type BacktraceReport,
type DebugIdContainer,
} from '@backtrace/sdk-core';
import { NativeModules, Platform } from 'react-native';
import { AnrException } from './anr/AnrException';
import { AnrReporter } from './anr/AnrReporter';
import { AnrWatchdogHandler } from './anr/AnrWatchdogHandler';
import { BacktraceAnrType, type BacktraceConfiguration } from './BacktraceConfiguration';
Expand All @@ -19,13 +22,16 @@ import { version } from './common/platformHelper';
import { version as agentVersion } from '../package.json';
import { CrashReporter } from './crashReporter/CrashReporter';
import { generateUnhandledExceptionHandler } from './handlers';
import { AndroidUnhandledException } from './handlers/android/AndroidUnhandledException';
import { type ExceptionHandler } from './handlers/ExceptionHandler';
import { ReactNativeRequestHandler } from './ReactNativeRequestHandler';
import { ReactStackTraceConverter } from './ReactStackTraceConverter';
import { type FileSystem } from './storage/FileSystem';

// Must match the private attribute name BreadcrumbsManager sets on JS reports.
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';

export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration> {
private _crashReporter?: CrashReporter;
Expand Down Expand Up @@ -85,6 +91,7 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
const lockId = this.sessionFiles?.lockPreviousSessions();
try {
super.initialize();
this.addProguardSymbolicationId();
this.captureUnhandledErrors(
this.options.captureUnhandledErrors,
this.options.captureUnhandledPromiseRejections,
Expand Down Expand Up @@ -145,6 +152,25 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
}
}

protected generateSubmissionData(report: BacktraceReport): BacktraceData | undefined {
if (this.options.proguard?.enable && this.hasJavaStackTrace(report)) {
report.symbolication = 'proguard';
}
return super.generateSubmissionData(report);
}

private hasJavaStackTrace(report: BacktraceReport): boolean {
return report.data instanceof AndroidUnhandledException || report.data instanceof AnrException;
}

private addProguardSymbolicationId(): void {
const proguard = this.options.proguard;
if (Platform.OS !== 'android' || !proguard?.enable || !proguard.symbolicationId) {
return;
}
this.addAttribute({ [SYMBOLICATION_ID_ATTRIBUTE_NAME]: proguard.symbolicationId });
}

private reportApplicationNotResponding(): void {
const anr = this.options.anr;
if (!anr?.enable) {
Expand Down
19 changes: 19 additions & 0 deletions packages/react-native/src/BacktraceConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,28 @@ export interface BacktraceAnrConfiguration {
disableWhenDebuggerAttached?: boolean;
}

export interface BacktraceProguardConfiguration {
/**
* Marks reports built from Java stack traces (unhandled Java exceptions, ANRs) for ProGuard/R8
* deobfuscation by Backtrace. Android only. By default the value is set to false.
*/
enable?: boolean;

/**
* Identifier of the ProGuard/R8 mapping file uploaded to Backtrace for this build. Sent as the
* `symbolication_id` attribute on every Android report. When not set, add that attribute yourself.
*/
symbolicationId?: string;
}

export interface BacktraceConfiguration extends SdkConfiguration {
/**
* Application Not Responding settings
*/
anr?: BacktraceAnrConfiguration;

/**
* ProGuard/R8 deobfuscation settings
*/
proguard?: BacktraceProguardConfiguration;
}
1 change: 1 addition & 0 deletions packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
BacktraceAnrType,
type BacktraceAnrConfiguration,
type BacktraceConfiguration,
type BacktraceProguardConfiguration,
} from './BacktraceConfiguration';
export { BacktraceClientBuilder } from './builder/BacktraceClientBuilder';
export { ErrorBoundary } from './ErrorBoundary';
Expand Down
154 changes: 154 additions & 0 deletions packages/react-native/tests/proguardSymbolicationTests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { BacktraceReport, type BacktraceData } from '@backtrace/sdk-core';
import { Platform } from 'react-native';
import { AnrException } from '../src/anr/AnrException';
import { AndroidUnhandledException } from '../src/handlers/android/AndroidUnhandledException';
import { mockStreamFileSystem } from './_mocks/fileSystem';

jest.mock('react-native', () => ({
NativeModules: {},
Platform: {
OS: 'android',
select: (options: Record<string, unknown>) =>
options.android !== undefined ? options.android : options.default,
},
}));

jest.mock('../src/common/platformHelper', () => ({
version: () => '0.81.6',
}));

jest.mock('../src/ReactNativeRequestHandler', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { BacktraceReportSubmissionResult } = require('@backtrace/sdk-core');
return {
ReactNativeRequestHandler: jest.fn().mockImplementation(() => ({
postError: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.ReportSkipped()),
post: jest.fn().mockResolvedValue(BacktraceReportSubmissionResult.ReportSkipped()),
})),
};
});

/* eslint-disable @typescript-eslint/no-var-requires */
const { BacktraceClient } = require('../src/BacktraceClient');
/* eslint-enable @typescript-eslint/no-var-requires */

const SYMBOLICATION_ID = 'f6c3e8d4-8626-4051-94ec-53e6daccce25';
const JAVA_FRAMES = [{ funcName: 'com.example.a.b', library: 'SourceFile', line: 1 }];

function createClient(proguard?: { enable?: boolean; symbolicationId?: string }) {
const client = new BacktraceClient({
options: {
url: 'https://submit.backtrace.io/universe/token/json',
captureUnhandledErrors: false,
captureUnhandledPromiseRejections: false,
database: { enable: false },
metrics: { enable: false },
breadcrumbs: { enable: false },
userAttributes: { application: 'proguardSymbolication', 'application.version': '1.0.0' },
proguard,
},
fileSystem: mockStreamFileSystem(),
});
client.initialize();
return client;
}

function javaExceptionReport() {
const report = new BacktraceReport(
new AndroidUnhandledException('java.lang.RuntimeException', 'boom', 'java.lang.RuntimeException: boom'),
{ 'error.type': 'Unhandled exception' },
);
report.addStackTrace('main', JAVA_FRAMES);
return report;
}

function anrReport() {
const report = new BacktraceReport(new AnrException('Application Not Responding | Blocked thread detected', ''), {
'error.type': 'Hang',
});
report.addStackTrace('main', JAVA_FRAMES);
return report;
}

async function submittedData(client: InstanceType<typeof BacktraceClient>, report: BacktraceReport) {
let submitted: BacktraceData | undefined;
client.on('before-send', (_report: BacktraceReport, data: BacktraceData) => {
submitted = data;
});
await client.send(report);
if (!submitted) {
throw new Error('report was not submitted');
}
return submitted;
}

describe('BacktraceClient proguard symbolication', () => {
let client: InstanceType<typeof BacktraceClient>;

afterEach(() => {
client?.dispose();
});

it('Should mark unhandled Java exception reports for proguard symbolication when enabled', async () => {
client = createClient({ enable: true, symbolicationId: SYMBOLICATION_ID });

const data = await submittedData(client, javaExceptionReport());

expect(data.symbolication).toEqual('proguard');
});

it('Should mark ANR reports for proguard symbolication when enabled', async () => {
client = createClient({ enable: true, symbolicationId: SYMBOLICATION_ID });

const data = await submittedData(client, anrReport());

expect(data.symbolication).toEqual('proguard');
});

it('Should leave JavaScript error reports without proguard symbolication', async () => {
client = createClient({ enable: true, symbolicationId: SYMBOLICATION_ID });

const data = await submittedData(client, new BacktraceReport(new Error('js')));

expect(data.symbolication).toBeUndefined();
});

it('Should send the configured id as the symbolication_id attribute on every report', async () => {
client = createClient({ enable: true, symbolicationId: SYMBOLICATION_ID });

const data = await submittedData(client, new BacktraceReport(new Error('js')));

expect(data.attributes['symbolication_id']).toEqual(SYMBOLICATION_ID);
});

it('Should not mark Java reports or add the attribute when proguard is not configured', async () => {
client = createClient();

const data = await submittedData(client, javaExceptionReport());

expect(data.symbolication).toBeUndefined();
expect(data.attributes['symbolication_id']).toBeUndefined();
});

it('Should mark Java reports without adding the attribute when only enable is set', async () => {
client = createClient({ enable: true });

const data = await submittedData(client, javaExceptionReport());

expect(data.symbolication).toEqual('proguard');
expect(data.attributes['symbolication_id']).toBeUndefined();
});

it('Should not add the symbolication_id attribute on iOS', async () => {
(Platform as { OS: string }).OS = 'ios';
try {
client = createClient({ enable: true, symbolicationId: SYMBOLICATION_ID });

const data = await submittedData(client, new BacktraceReport(new Error('js')));

expect(data.attributes['symbolication_id']).toBeUndefined();
} finally {
(Platform as { OS: string }).OS = 'android';
}
});
});
4 changes: 3 additions & 1 deletion packages/sdk-core/src/model/data/BacktraceData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { BacktraceStackTrace } from './BacktraceStackTrace.js';

export type AttributeType = string | number | boolean | undefined | null;

export type BacktraceSymbolication = 'sourcemap' | 'proguard';

export interface BacktraceData {
uuid: string;
timestamp: number;
Expand All @@ -14,5 +16,5 @@ export interface BacktraceData {
attributes: Record<string, AttributeType>;
annotations: Record<string, unknown>;
threads: Record<string, BacktraceStackTrace>;
symbolication?: 'sourcemap';
symbolication?: BacktraceSymbolication;
}
15 changes: 14 additions & 1 deletion packages/sdk-core/src/model/report/BacktraceReport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { jsonEscaper } from '../../common/jsonEscaper.js';
import { TimeHelper } from '../../common/TimeHelper.js';
import { BacktraceAttachment } from '../attachment/index.js';
import { BacktraceSymbolication } from '../data/BacktraceData.js';
import { BacktraceStackFrame } from '../data/BacktraceStackTrace.js';
import { BacktraceErrorType } from './BacktraceErrorType.js';
import { BacktraceReportStackTraceInfo } from './BacktraceReportStackTraceInfo.js';
Expand Down Expand Up @@ -39,6 +40,12 @@ export class BacktraceReport {
*/
public skipFrames = 0;

/**
* Symbolication Backtrace should apply to the report frames.
* When not set, 'sourcemap' is used if a debug identifier is found in the frames.
*/
public symbolication?: BacktraceSymbolication;

/**
* Add additional stack trace to the report.
* If the thread name already exists it will be overwritten
Expand All @@ -65,9 +72,15 @@ export class BacktraceReport {
public readonly data: Error | string,
public readonly attributes: Record<string, unknown> = {},
public readonly attachments: BacktraceAttachment[] = [],
options: { skipFrames?: number; classifiers?: string[]; timestamp?: number } = {},
options: {
skipFrames?: number;
classifiers?: string[];
timestamp?: number;
symbolication?: BacktraceSymbolication;
} = {},
) {
this.skipFrames = options?.skipFrames ?? 0;
this.symbolication = options?.symbolication;
let errorType: BacktraceErrorType = 'Exception';
if (data instanceof Error) {
this.message = this.generateErrorMessage(data.message);
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk-core/src/modules/data/BacktraceDataBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export class BacktraceDataBuilder {
},
};

if (detectedDebugIdentifier) {
if (report.symbolication) {
result.symbolication = report.symbolication;
} else if (detectedDebugIdentifier) {
result.symbolication = 'sourcemap';
}

Expand Down
Loading
Loading