From fde71c6cae7ac4cf63c7193b86e78dca91afb9be Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 15 Jan 2026 16:44:11 +0100 Subject: [PATCH 1/3] chore: Merge Android UI profiling on the capture startup crashes branch --- CHANGELOG.md | 4 + .../io/sentry/react/RNSentryModuleImpl.java | 12 +- .../java/io/sentry/react/RNSentryStart.java | 55 ++++++ packages/core/src/js/client.ts | 1 + packages/core/src/js/options.ts | 55 +++++- packages/core/src/js/wrapper.ts | 13 +- packages/core/test/androidProfiling.test.ts | 159 ++++++++++++++++++ 7 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 packages/core/test/androidProfiling.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b04d231404..a7d15136db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Experimental support of UI profiling on Android ([#5518](https://github.com/getsentry/sentry-react-native/pull/5518)) + ### Fixes - Fix duplicate error reporting on iOS with New Architecture ([#5532](https://github.com/getsentry/sentry-react-native/pull/5532)) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 6ff9e309e9..e3830d4691 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -192,12 +192,14 @@ public void crash() { } public void addListener(String eventType) { - // Is must be defined otherwise the generated interface from TS won't be fulfilled + // Is must be defined otherwise the generated interface from TS won't be + // fulfilled logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!"); } public void removeListeners(double id) { - // Is must be defined otherwise the generated interface from TS won't be fulfilled + // Is must be defined otherwise the generated interface from TS won't be + // fulfilled logger.log( SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!"); } @@ -262,7 +264,8 @@ protected void fetchNativeAppStart( // When activity is destroyed but the application process is kept alive // the next activity creation is considered warm start. // The app start metrics will be updated by the the Android SDK. - // To let the RN JS layer know these are new start data we compare the start timestamps. + // To let the RN JS layer know these are new start data we compare the start + // timestamps. lastStartTimestampMs = currentStartTimestampMs; // Clears start metrics, making them ready for recording warm app start @@ -952,7 +955,8 @@ protected void trySetIgnoreErrors(SentryAndroidOptions options, ReadableMap rnOp } } if (strErrors != null) { - // Use the same behaviour of JavaScript instead of Android when dealing with strings. + // Use the same behaviour of JavaScript instead of Android when dealing with + // strings. for (int i = 0; i < strErrors.size(); i++) { String pattern = ".*" + Pattern.quote(strErrors.getString(i)) + ".*"; list.add(pattern); diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index fcdacd964e..4db044686c 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -7,6 +7,7 @@ import com.facebook.react.common.JavascriptException; import io.sentry.ILogger; import io.sentry.Integration; +import io.sentry.ProfileLifecycle; import io.sentry.Sentry; import io.sentry.SentryEvent; import io.sentry.SentryLevel; @@ -162,6 +163,9 @@ static void getSentryAndroidOptions( options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); } + // Configure Android UI Profiling + configureAndroidProfiling(options, rnOptions); + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs String dsn = getURLFromDSN(rnOptions.getString("dsn")); String devServerUrl = rnOptions.getString("devServerUrl"); @@ -192,6 +196,57 @@ static void getSentryAndroidOptions( SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); } + private void configureAndroidProfiling( + @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions) { + if (!rnOptions.hasKey("_experiments")) { + return; + } + + @Nullable final ReadableMap experiments = rnOptions.getMap("_experiments"); + if (experiments == null || !experiments.hasKey("androidProfilingOptions")) { + return; + } + + @Nullable + final ReadableMap androidProfilingOptions = experiments.getMap("androidProfilingOptions"); + if (androidProfilingOptions == null) { + return; + } + + // Set profile session sample rate + if (androidProfilingOptions.hasKey("profileSessionSampleRate")) { + final double profileSessionSampleRate = + androidProfilingOptions.getDouble("profileSessionSampleRate"); + options.setProfileSessionSampleRate(profileSessionSampleRate); + logger.log( + SentryLevel.INFO, + String.format( + "Android UI Profiling profileSessionSampleRate set to: %.2f", + profileSessionSampleRate)); + } + + // Set profiling lifecycle mode + if (androidProfilingOptions.hasKey("lifecycle")) { + final String lifecycle = androidProfilingOptions.getString("lifecycle"); + if ("manual".equalsIgnoreCase(lifecycle)) { + options.setProfileLifecycle(ProfileLifecycle.MANUAL); + logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to MANUAL"); + } else if ("trace".equalsIgnoreCase(lifecycle)) { + options.setProfileLifecycle(ProfileLifecycle.TRACE); + logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to TRACE"); + } + } + + // Set start on app start + if (androidProfilingOptions.hasKey("startOnAppStart")) { + final boolean startOnAppStart = androidProfilingOptions.getBoolean("startOnAppStart"); + options.setStartProfilerOnAppStart(startOnAppStart); + logger.log( + SentryLevel.INFO, + String.format("Android UI Profiling startOnAppStart set to %b", startOnAppStart)); + } + } + /** * This function updates the options with RNSentry defaults. These default can be overwritten by * users during manual native initialization. diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index 42f3659c2e..699c94cd0e 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -224,6 +224,7 @@ export class ReactNativeClient extends Client { 'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] ? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType).options : undefined, + androidProfilingOptions: this._options._experiments?.androidProfilingOptions, }) .then( (result: boolean) => { diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index cfcd6a0c3f..859e876839 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -285,7 +285,7 @@ export interface BaseReactNativeOptions { /** * Experiment: A more reliable way to report unhandled C++ exceptions in iOS. * - * This approach hooks into all instances of the `__cxa_throw` function, which provides a more comprehensive and consistent exception handling across an app’s runtime, regardless of the number of C++ modules or how they’re linked. It helps in obtaining accurate stack traces. + * This approach hooks into all instances of the `__cxa_throw` function, which provides a more comprehensive and consistent exception handling across an app's runtime, regardless of the number of C++ modules or how they're linked. It helps in obtaining accurate stack traces. * * - Note: The mechanism of hooking into `__cxa_throw` could cause issues with symbolication on iOS due to caching of symbol references. * @@ -293,6 +293,17 @@ export interface BaseReactNativeOptions { * @platform ios */ enableUnhandledCPPExceptionsV2?: boolean; + + /** + * Configuration options for Android UI profiling. + * UI profiling supports two modes: `manual` and `trace`. + * - In `trace` mode, the profiler runs based on active sampled spans. + * - In `manual` mode, profiling is controlled via start/stop API calls. + * + * @experimental + * @platform android + */ + androidProfilingOptions?: AndroidProfilingOptions; }; /** @@ -330,6 +341,48 @@ export interface BaseReactNativeOptions { export type SentryReplayQuality = 'low' | 'medium' | 'high'; +/** + * Android UI profiling lifecycle modes. + * - `trace`: Profiler runs based on active sampled spans + * - `manual`: Profiler is controlled manually via start/stop API calls + */ +export type AndroidProfilingLifecycle = 'trace' | 'manual'; + +/** + * Configuration options for Android UI profiling. + * + * @experimental + * @platform android + */ +export interface AndroidProfilingOptions { + /** + * Sample rate for profiling sessions. + * This is evaluated once per session and determines if profiling should be enabled for that session. + * 1.0 will enable profiling for all sessions, 0.0 will disable profiling. + * + * @default undefined (profiling disabled) + */ + profileSessionSampleRate?: number; + + /** + * Profiling lifecycle mode. + * - `trace`: Profiler runs while there is at least one active sampled span + * - `manual`: Profiler is controlled manually via Sentry.profiler.startProfiler/stopProfiler + * + * @default 'trace' + */ + lifecycle?: AndroidProfilingLifecycle; + + /** + * Enable profiling on app start. + * - In `trace` mode: The app start profile stops automatically when the app start root span finishes + * - In `manual` mode: The app start profile must be stopped through Sentry.profiler.stopProfiler() + * + * @default false + */ + startOnAppStart?: boolean; +} + export interface ReactNativeTransportOptions extends BrowserTransportOptions { /** * @deprecated use `maxQueueSize` in the root of the SDK options. diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 8552cdac7c..4089bc5c75 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -22,7 +22,7 @@ import type { NativeStackFrames, Spec, } from './NativeRNSentry'; -import type { ReactNativeClientOptions } from './options'; +import type { AndroidProfilingOptions, ReactNativeClientOptions } from './options'; import type * as Hermes from './profiling/hermes'; import type { NativeAndroidProfileEvent, NativeProfileEvent } from './profiling/nativeTypes'; import type { MobileReplayOptions } from './replay/mobilereplay'; @@ -57,6 +57,7 @@ export type NativeSdkOptions = Partial & { ignoreErrorsRegex?: string[] | undefined; } & { mobileReplayOptions: MobileReplayOptions | undefined; + androidProfilingOptions?: AndroidProfilingOptions | undefined; }; interface SentryNativeWrapper { @@ -286,9 +287,19 @@ export const NATIVE: SentryNativeWrapper = { integrations, ignoreErrors, logsOrigin, + androidProfilingOptions, ...filteredOptions } = options; /* eslint-enable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */ + + // Move androidProfilingOptions into _experiments + if (androidProfilingOptions) { + filteredOptions._experiments = { + ...filteredOptions._experiments, + androidProfilingOptions, + }; + } + const nativeIsReady = await RNSentry.initNativeSdk(filteredOptions); this.nativeIsReady = nativeIsReady; diff --git a/packages/core/test/androidProfiling.test.ts b/packages/core/test/androidProfiling.test.ts new file mode 100644 index 0000000000..68de0f1ca9 --- /dev/null +++ b/packages/core/test/androidProfiling.test.ts @@ -0,0 +1,159 @@ +import type { Spec } from '../src/js/NativeRNSentry'; +import type { ReactNativeClientOptions } from '../src/js/options'; +import { NATIVE } from '../src/js/wrapper'; + +jest.mock('react-native', () => { + let initPayload: ReactNativeClientOptions | null = null; + + const RNSentry: Spec = { + addBreadcrumb: jest.fn(), + captureEnvelope: jest.fn(), + clearBreadcrumbs: jest.fn(), + crashedLastRun: jest.fn(), + crash: jest.fn(), + fetchNativeDeviceContexts: jest.fn(() => + Promise.resolve({ + someContext: { + someValue: 0, + }, + }), + ), + fetchNativeRelease: jest.fn(() => + Promise.resolve({ + build: '1.0.0.1', + id: 'test-mock', + version: '1.0.0', + }), + ), + setContext: jest.fn(), + setExtra: jest.fn(), + setTag: jest.fn(), + setUser: jest.fn(() => { + return; + }), + initNativeSdk: jest.fn(options => { + initPayload = options; + return Promise.resolve(true); + }), + closeNativeSdk: jest.fn(() => Promise.resolve()), + // @ts-expect-error for testing. + _getLastPayload: () => ({ initPayload }), + startProfiling: jest.fn(), + stopProfiling: jest.fn(), + }; + + return { + NativeModules: { + RNSentry, + }, + Platform: { + OS: 'android', + }, + }; +}); + +const RNSentry = require('react-native').NativeModules.RNSentry as Spec; + +describe('Android UI Profiling Options', () => { + beforeEach(() => { + NATIVE.platform = 'android'; + NATIVE.enableNative = true; + jest.clearAllMocks(); + }); + + it('passes androidProfilingOptions to native SDK', async () => { + await NATIVE.initNativeSdk({ + dsn: 'https://example@sentry.io/123', + enableNative: true, + autoInitializeNativeSdk: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + androidProfilingOptions: { + profileSessionSampleRate: 0.5, + lifecycle: 'trace', + startOnAppStart: true, + }, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + _experiments: expect.objectContaining({ + androidProfilingOptions: { + profileSessionSampleRate: 0.5, + lifecycle: 'trace', + startOnAppStart: true, + }, + }), + }), + ); + }); + + it('passes androidProfilingOptions with manual lifecycle', async () => { + await NATIVE.initNativeSdk({ + dsn: 'https://example@sentry.io/123', + enableNative: true, + autoInitializeNativeSdk: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + androidProfilingOptions: { + profileSessionSampleRate: 1.0, + lifecycle: 'manual', + startOnAppStart: false, + }, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + _experiments: expect.objectContaining({ + androidProfilingOptions: { + profileSessionSampleRate: 1.0, + lifecycle: 'manual', + startOnAppStart: false, + }, + }), + }), + ); + }); + + it('does not pass androidProfilingOptions when undefined', async () => { + await NATIVE.initNativeSdk({ + dsn: 'https://example@sentry.io/123', + enableNative: true, + autoInitializeNativeSdk: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + androidProfilingOptions: undefined, + }); + + const callArgs = (RNSentry.initNativeSdk as jest.Mock).mock.calls[0][0]; + expect(callArgs._experiments?.androidProfilingOptions).toBeUndefined(); + }); + + it('handles partial androidProfilingOptions', async () => { + await NATIVE.initNativeSdk({ + dsn: 'https://example@sentry.io/123', + enableNative: true, + autoInitializeNativeSdk: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + androidProfilingOptions: { + profileSessionSampleRate: 0.3, + // lifecycle and startOnAppStart not provided + }, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + _experiments: expect.objectContaining({ + androidProfilingOptions: { + profileSessionSampleRate: 0.3, + }, + }), + }), + ); + }); +}); From f03818ac3698ddd6fa44dcab3c88f54b676d2fcc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 15 Jan 2026 17:39:49 +0100 Subject: [PATCH 2/3] Fix logger compilation issue --- .../src/main/java/io/sentry/react/RNSentryStart.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index 4db044686c..67fa8e0973 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -164,7 +164,7 @@ static void getSentryAndroidOptions( } // Configure Android UI Profiling - configureAndroidProfiling(options, rnOptions); + configureAndroidProfiling(options, rnOptions, logger); // Exclude Dev Server and Sentry Dsn request from Breadcrumbs String dsn = getURLFromDSN(rnOptions.getString("dsn")); @@ -196,8 +196,10 @@ static void getSentryAndroidOptions( SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); } - private void configureAndroidProfiling( - @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions) { + private static void configureAndroidProfiling( + @NotNull SentryAndroidOptions options, + @NotNull ReadableMap rnOptions, + @NotNull ILogger logger) { if (!rnOptions.hasKey("_experiments")) { return; } From f1b17ef0aa3a565b5079df26bbf242fce3d5d116 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 15 Jan 2026 17:48:27 +0100 Subject: [PATCH 3/3] Properly check logging values --- .../java/io/sentry/react/RNSentryStart.java | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index 67fa8e0973..b3eb4510c7 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -217,35 +217,54 @@ private static void configureAndroidProfiling( // Set profile session sample rate if (androidProfilingOptions.hasKey("profileSessionSampleRate")) { - final double profileSessionSampleRate = - androidProfilingOptions.getDouble("profileSessionSampleRate"); - options.setProfileSessionSampleRate(profileSessionSampleRate); - logger.log( - SentryLevel.INFO, - String.format( - "Android UI Profiling profileSessionSampleRate set to: %.2f", - profileSessionSampleRate)); + if (androidProfilingOptions.getType("profileSessionSampleRate") == ReadableType.Number) { + final double profileSessionSampleRate = + androidProfilingOptions.getDouble("profileSessionSampleRate"); + options.setProfileSessionSampleRate(profileSessionSampleRate); + logger.log( + SentryLevel.INFO, + String.format( + "Android UI Profiling profileSessionSampleRate set to: %.2f", + profileSessionSampleRate)); + } else { + logger.log( + SentryLevel.WARNING, + "Android UI Profiling profileSessionSampleRate must be a number, ignoring invalid" + + " value"); + } } // Set profiling lifecycle mode if (androidProfilingOptions.hasKey("lifecycle")) { - final String lifecycle = androidProfilingOptions.getString("lifecycle"); - if ("manual".equalsIgnoreCase(lifecycle)) { - options.setProfileLifecycle(ProfileLifecycle.MANUAL); - logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to MANUAL"); - } else if ("trace".equalsIgnoreCase(lifecycle)) { - options.setProfileLifecycle(ProfileLifecycle.TRACE); - logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to TRACE"); + if (androidProfilingOptions.getType("lifecycle") == ReadableType.String) { + final String lifecycle = androidProfilingOptions.getString("lifecycle"); + if ("manual".equalsIgnoreCase(lifecycle)) { + options.setProfileLifecycle(ProfileLifecycle.MANUAL); + logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to MANUAL"); + } else if ("trace".equalsIgnoreCase(lifecycle)) { + options.setProfileLifecycle(ProfileLifecycle.TRACE); + logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to TRACE"); + } + } else { + logger.log( + SentryLevel.WARNING, + "Android UI Profiling lifecycle must be a string, ignoring invalid value"); } } // Set start on app start if (androidProfilingOptions.hasKey("startOnAppStart")) { - final boolean startOnAppStart = androidProfilingOptions.getBoolean("startOnAppStart"); - options.setStartProfilerOnAppStart(startOnAppStart); - logger.log( - SentryLevel.INFO, - String.format("Android UI Profiling startOnAppStart set to %b", startOnAppStart)); + if (androidProfilingOptions.getType("startOnAppStart") == ReadableType.Boolean) { + final boolean startOnAppStart = androidProfilingOptions.getBoolean("startOnAppStart"); + options.setStartProfilerOnAppStart(startOnAppStart); + logger.log( + SentryLevel.INFO, + String.format("Android UI Profiling startOnAppStart set to %b", startOnAppStart)); + } else { + logger.log( + SentryLevel.WARNING, + "Android UI Profiling startOnAppStart must be a boolean, ignoring invalid value"); + } } }