From ae81bd39f31ec54ae548789040fa682318129f91 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 8 Sep 2026 13:54:20 +0200 Subject: [PATCH 1/2] react-native: ship consumer proguard rules, minify the example release build in ci --- .github/workflows/android.yml | 2 +- examples/sdk/reactNative/android/app/build.gradle | 2 +- packages/react-native/android/build.gradle | 1 + packages/react-native/android/consumer-rules.pro | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/android/consumer-rules.pro diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 375b9736..796c0f86 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 diff --git a/examples/sdk/reactNative/android/app/build.gradle b/examples/sdk/reactNative/android/app/build.gradle index 55a7af09..bbb3d27a 100644 --- a/examples/sdk/reactNative/android/app/build.gradle +++ b/examples/sdk/reactNative/android/app/build.gradle @@ -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" } } diff --git a/packages/react-native/android/build.gradle b/packages/react-native/android/build.gradle index eb640544..12ddb8a0 100644 --- a/packages/react-native/android/build.gradle +++ b/packages/react-native/android/build.gradle @@ -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 { diff --git a/packages/react-native/android/consumer-rules.pro b/packages/react-native/android/consumer-rules.pro new file mode 100644 index 00000000..9b499a26 --- /dev/null +++ b/packages/react-native/android/consumer-rules.pro @@ -0,0 +1 @@ +-keep class backtraceio.** { *; } From e0a661300cf8a904c1d7bade043a21dc2b366899 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 8 Sep 2026 14:10:39 +0200 Subject: [PATCH 2/2] sdk-core, react-native: add proguard symbolication for java exception and anr reports --- packages/react-native/README.md | 28 ++++ packages/react-native/src/BacktraceClient.ts | 26 +++ .../src/BacktraceConfiguration.ts | 19 +++ packages/react-native/src/index.ts | 1 + .../tests/proguardSymbolicationTests.spec.ts | 154 ++++++++++++++++++ .../sdk-core/src/model/data/BacktraceData.ts | 4 +- .../src/model/report/BacktraceReport.ts | 15 +- .../src/modules/data/BacktraceDataBuilder.ts | 4 +- .../tests/report/dataGenerationTests.spec.ts | 23 +++ 9 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/tests/proguardSymbolicationTests.spec.ts diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 5e629c35..83fac23d 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -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) @@ -85,6 +86,32 @@ your original source identifiers. +### 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///json', + proguard: { + enable: true, + symbolicationId: '', + }, +}; +``` + +``` +curl --data-binary @android/app/build/outputs/mapping/release/mapping.txt -X POST -H "Expect:" "https://submit.backtrace.io///proguard?symbolication_id=" +``` + +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 @@ -430,6 +457,7 @@ The following options are available for the BacktraceClientOptions passed when i | `metrics` | BacktraceMetricsOptions | See [Backtrace Stability Metrics](#application-stability-metrics) | |
  • - [ ]
| | `breadcrumbs` | BacktraceBreadcrumbsSettings | See [Backtrace Breadcrumbs](#breadcrumbs) | |
  • - [ ]
| | `database` | BacktraceDatabaseSettings | See [Backtrace Database](#offline-database-support) | |
  • - [ ]
| +| `proguard` | BacktraceProguardConfiguration | See [Deobfuscate ProGuard and R8 builds](#deobfuscate-proguard-and-r8-builds-android) | |
  • - [ ]
| ### Manually send an error diff --git a/packages/react-native/src/BacktraceClient.ts b/packages/react-native/src/BacktraceClient.ts index 9befe665..f3dc5a66 100644 --- a/packages/react-native/src/BacktraceClient.ts +++ b/packages/react-native/src/BacktraceClient.ts @@ -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'; @@ -19,6 +22,7 @@ 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'; @@ -26,6 +30,8 @@ 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 { private _crashReporter?: CrashReporter; @@ -85,6 +91,7 @@ export class BacktraceClient extends BacktraceCoreClient const lockId = this.sessionFiles?.lockPreviousSessions(); try { super.initialize(); + this.addProguardSymbolicationId(); this.captureUnhandledErrors( this.options.captureUnhandledErrors, this.options.captureUnhandledPromiseRejections, @@ -145,6 +152,25 @@ export class BacktraceClient extends BacktraceCoreClient } } + 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) { diff --git a/packages/react-native/src/BacktraceConfiguration.ts b/packages/react-native/src/BacktraceConfiguration.ts index 56b092ec..61256444 100644 --- a/packages/react-native/src/BacktraceConfiguration.ts +++ b/packages/react-native/src/BacktraceConfiguration.ts @@ -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; } diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index b19a5ab1..d2a78758 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -17,6 +17,7 @@ export { BacktraceAnrType, type BacktraceAnrConfiguration, type BacktraceConfiguration, + type BacktraceProguardConfiguration, } from './BacktraceConfiguration'; export { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; export { ErrorBoundary } from './ErrorBoundary'; diff --git a/packages/react-native/tests/proguardSymbolicationTests.spec.ts b/packages/react-native/tests/proguardSymbolicationTests.spec.ts new file mode 100644 index 00000000..1f964b65 --- /dev/null +++ b/packages/react-native/tests/proguardSymbolicationTests.spec.ts @@ -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) => + 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, 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; + + 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'; + } + }); +}); diff --git a/packages/sdk-core/src/model/data/BacktraceData.ts b/packages/sdk-core/src/model/data/BacktraceData.ts index 1f74c624..9dab854d 100644 --- a/packages/sdk-core/src/model/data/BacktraceData.ts +++ b/packages/sdk-core/src/model/data/BacktraceData.ts @@ -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; @@ -14,5 +16,5 @@ export interface BacktraceData { attributes: Record; annotations: Record; threads: Record; - symbolication?: 'sourcemap'; + symbolication?: BacktraceSymbolication; } diff --git a/packages/sdk-core/src/model/report/BacktraceReport.ts b/packages/sdk-core/src/model/report/BacktraceReport.ts index 0c548a78..ce9b2923 100644 --- a/packages/sdk-core/src/model/report/BacktraceReport.ts +++ b/packages/sdk-core/src/model/report/BacktraceReport.ts @@ -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'; @@ -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 @@ -65,9 +72,15 @@ export class BacktraceReport { public readonly data: Error | string, public readonly attributes: Record = {}, 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); diff --git a/packages/sdk-core/src/modules/data/BacktraceDataBuilder.ts b/packages/sdk-core/src/modules/data/BacktraceDataBuilder.ts index 4e1399fa..a4fa841c 100644 --- a/packages/sdk-core/src/modules/data/BacktraceDataBuilder.ts +++ b/packages/sdk-core/src/modules/data/BacktraceDataBuilder.ts @@ -45,7 +45,9 @@ export class BacktraceDataBuilder { }, }; - if (detectedDebugIdentifier) { + if (report.symbolication) { + result.symbolication = report.symbolication; + } else if (detectedDebugIdentifier) { result.symbolication = 'sourcemap'; } diff --git a/packages/sdk-core/tests/report/dataGenerationTests.spec.ts b/packages/sdk-core/tests/report/dataGenerationTests.spec.ts index c1ba756b..7af1fad3 100644 --- a/packages/sdk-core/tests/report/dataGenerationTests.spec.ts +++ b/packages/sdk-core/tests/report/dataGenerationTests.spec.ts @@ -108,6 +108,29 @@ describe('Data generation tests', () => { expect(backtraceData.threads[backtraceData.mainThread].stack).toEqual(expectedFrames); }); + it('Should not set symbolication when no debug identifier is found and no hint is given', () => { + const backtraceData = dataBuilder.build(new BacktraceReport(new Error())); + + expect(backtraceData.symbolication).toBeUndefined(); + }); + + it('Should use the symbolication passed in the report options', () => { + const backtraceData = dataBuilder.build( + new BacktraceReport(new Error(), {}, [], { symbolication: 'proguard' }), + ); + + expect(backtraceData.symbolication).toEqual('proguard'); + }); + + it('Should use the symbolication set on the report after creation', () => { + const report = new BacktraceReport(new Error()); + report.symbolication = 'proguard'; + + const backtraceData = dataBuilder.build(report); + + expect(backtraceData.symbolication).toEqual('proguard'); + }); + it('Should generate threads from BacktraceReport.stackTrace', () => { const errorReport = new BacktraceReport(new Error()); const secondName = 'second-stack-name';