From a5451ae9da5fba54fe2d9360e462cba5f8046e87 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 8 Jan 2026 15:45:19 +0100 Subject: [PATCH 1/5] Experimental: Android UI profiling --- .../io/sentry/react/RNSentryModuleImpl.java | 190 +++++++++++------- packages/core/src/js/client.ts | 1 + packages/core/src/js/options.ts | 55 ++++- packages/core/src/js/wrapper.ts | 1 + packages/core/test/androidProfiling.test.ts | 159 +++++++++++++++ 5 files changed, 333 insertions(+), 73 deletions(-) create mode 100644 packages/core/test/androidProfiling.test.ts 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 b4738aa332..fc355c84da 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 @@ -116,7 +116,8 @@ public class RNSentryModuleImpl { private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; - @VisibleForTesting static long lastStartTimestampMs = -1; + @VisibleForTesting + static long lastStartTimestampMs = -1; // 700ms to constitute frozen frames. private static final int FROZEN_FRAME_THRESHOLD = 700; @@ -126,7 +127,8 @@ public class RNSentryModuleImpl { private static final int SCREENSHOT_TIMEOUT_SECONDS = 2; /** - * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible + * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 + * to avoid possible * lockstep sampling. More on * https://stackoverflow.com/questions/45470758/what-is-lockstep-sampling */ @@ -171,13 +173,12 @@ private ReactApplicationContext getReactApplicationContext() { } private void initFragmentInitialFrameTracking() { - final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = - new RNSentryReactFragmentLifecycleTracer(buildInfo, emitNewFrameEvent, logger); + final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReactFragmentLifecycleTracer( + buildInfo, emitNewFrameEvent, logger); final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); if (fragmentActivity != null) { - final @Nullable FragmentManager supportFragmentManager = - fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -185,8 +186,8 @@ private void initFragmentInitialFrameTracking() { } private void initFragmentReplayTracking() { - final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = - new RNSentryReplayFragmentLifecycleTracer(logger); + final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReplayFragmentLifecycleTracer( + logger); final @Nullable Activity currentActivity = getCurrentActivity(); if (!(currentActivity instanceof FragmentActivity)) { @@ -194,8 +195,7 @@ private void initFragmentReplayTracking() { } final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity; - final @Nullable FragmentManager supportFragmentManager = - fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -225,7 +225,8 @@ protected Context getApplicationContext() { protected void getSentryAndroidOptions( @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable SdkVersion sdkVersion = options.getSdkVersion(); + @Nullable + SdkVersion sdkVersion = options.getSdkVersion(); if (sdkVersion == null) { sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); } else { @@ -324,15 +325,18 @@ protected void getSentryAndroidOptions( SentryReplayOptions replayOptions = getReplayOptions(rnOptions); options.setSessionReplay(replayOptions); - // Check if the replay integration is available on the classpath. It's already kept from R8 + // Check if the replay integration is available on the classpath. It's already + // kept from R8 // shrinking by sentry-android-core - final boolean isReplayAvailable = - loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); + final boolean isReplayAvailable = loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); if (isReplayEnabled(replayOptions) && isReplayAvailable) { options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); initFragmentReplayTracking(); } + // 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"); @@ -389,13 +393,11 @@ private boolean isReplayEnabled(SentryReplayOptions replayOptions) { } private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = - new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + final SdkVersion replaySdkVersion = new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); @NotNull - final SentryReplayOptions androidReplayOptions = - new SentryReplayOptions(false, replaySdkVersion); + final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(false, replaySdkVersion); if (!(rnOptions.hasKey("replaysSessionSampleRate") || rnOptions.hasKey("replaysOnErrorSampleRate"))) { @@ -420,7 +422,8 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { if (!rnOptions.hasKey("mobileReplayOptions")) { return androidReplayOptions; } - @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + @Nullable + final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); if (rnMobileReplayOptions == null) { return androidReplayOptions; } @@ -432,17 +435,15 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { !rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); - final boolean redactVectors = - !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); + final boolean redactVectors = !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); if (redactVectors) { androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg } if (rnMobileReplayOptions.hasKey("screenshotStrategy")) { final String screenshotStrategyString = rnMobileReplayOptions.getString("screenshotStrategy"); - final ScreenshotStrategyType screenshotStrategy = - parseScreenshotStrategy(screenshotStrategyString); + final ScreenshotStrategyType screenshotStrategy = parseScreenshotStrategy(screenshotStrategyString); androidReplayOptions.setScreenshotStrategy(screenshotStrategy); } @@ -482,17 +483,73 @@ private SentryReplayQuality parseReplayQuality(@Nullable String qualityString) { } } + 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.setProfilingLifecycle( + io.sentry.android.core.SentryAndroidOptions.ProfilingLifecycle.MANUAL); + logger.log(SentryLevel.INFO, "Android UI Profiling lifecycle set to: MANUAL"); + } else if ("trace".equalsIgnoreCase(lifecycle)) { + options.setProfilingLifecycle( + io.sentry.android.core.SentryAndroidOptions.ProfilingLifecycle.TRACE); + logger.log(SentryLevel.INFO, "Android UI Profiling lifecycle set to: TRACE"); + } + } + + // Set start on app start + if (androidProfilingOptions.hasKey("startOnAppStart")) { + final boolean startOnAppStart = androidProfilingOptions.getBoolean("startOnAppStart"); + options.setStartProfilingOnAppStart(startOnAppStart); + logger.log( + SentryLevel.INFO, + String.format( + "Android UI Profiling startOnAppStart set to: %b", startOnAppStart)); + } + } + public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } 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!"); } @@ -538,12 +595,10 @@ protected void fetchNativeAppStart( return; } - WritableMap mutableMeasurement = - (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); + WritableMap mutableMeasurement = (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs(); - boolean hasFetched = - lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; + boolean hasFetched = lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; mutableMeasurement.putBoolean("has_fetched", hasFetched); if (lastStartTimestampMs < 0) { @@ -557,7 +612,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 @@ -675,12 +731,11 @@ public void captureScreenshot(Promise promise) { private static byte[] takeScreenshotOnUiThread(Activity activity) { CountDownLatch doneSignal = new CountDownLatch(1); - final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable - final Runnable runTakeScreenshot = - () -> { - bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); - doneSignal.countDown(); - }; + final byte[][] bytesWrapper = { {} }; // wrapper to be able to set the value in the runnable + final Runnable runTakeScreenshot = () -> { + bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); + doneSignal.countDown(); + }; if (UiThreadUtil.isOnUiThread()) { runTakeScreenshot.run(); @@ -700,8 +755,7 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { public void fetchViewHierarchy(Promise promise) { final @Nullable Activity activity = getCurrentActivity(); - final @Nullable ViewHierarchy viewHierarchy = - ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); + final @Nullable ViewHierarchy viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); promise.resolve(null); @@ -709,8 +763,7 @@ public void fetchViewHierarchy(Promise promise) { } ISerializer serializer = ScopesAdapter.getInstance().getOptions().getSerializer(); - final @Nullable byte[] bytes = - JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); + final @Nullable byte[] bytes = JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); if (bytes == null) { logger.log(SentryLevel.ERROR, "Could not serialize ViewHierarchy."); promise.resolve(null); @@ -920,8 +973,7 @@ public void getNewScreenTimeToDisplay(Promise promise) { private String getProfilingTracesDirPath() { if (cacheDirPath == null) { - cacheDirPath = - new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); + cacheDirPath = new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); } File profilingTraceDir = new File(cacheDirPath, "profiling_trace"); profilingTraceDir.mkdirs(); @@ -934,13 +986,12 @@ private void initializeAndroidProfiler() { } final String tracesFilesDirPath = getProfilingTracesDirPath(); - androidProfiler = - new AndroidProfiler( - tracesFilesDirPath, - (int) SECONDS.toMicros(1) / profilingTracesHz, - new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), - executorService, - logger); + androidProfiler = new AndroidProfiler( + tracesFilesDirPath, + (int) SECONDS.toMicros(1) / profilingTracesHz, + new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), + executorService, + logger); } public WritableMap startProfiling(boolean platformProfilers) { @@ -974,9 +1025,8 @@ public WritableMap stopProfiling() { } HermesSamplingProfiler.disable(); - output = - File.createTempFile( - "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); + output = File.createTempFile( + "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); if (isDebug) { logger.log(SentryLevel.INFO, "Profile saved to: " + output.getAbsolutePath()); } @@ -986,10 +1036,8 @@ public WritableMap stopProfiling() { if (end != null) { WritableMap androidProfile = new WritableNativeMap(); - byte[] androidProfileBytes = - FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); - String base64AndroidProfile = - Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); + byte[] androidProfileBytes = FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); + String base64AndroidProfile = Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); androidProfile.putString("sampled_profile", base64AndroidProfile); androidProfile.putInt("android_api_level", buildInfo.getSdkInfoVersion()); @@ -1018,8 +1066,8 @@ public WritableMap stopProfiling() { return proguardUuid; } isProguardDebugMetaLoaded = true; - final @Nullable List debugMetaList = - new AssetsDebugMetaLoader(this.getReactApplicationContext(), logger).loadDebugMeta(); + final @Nullable List debugMetaList = new AssetsDebugMetaLoader(this.getReactApplicationContext(), + logger).loadDebugMeta(); if (debugMetaList == null) { return null; } @@ -1037,7 +1085,7 @@ public WritableMap stopProfiling() { } private String readStringFromFile(File path) throws IOException { - try (BufferedReader br = new BufferedReader(new FileReader(path)); ) { + try (BufferedReader br = new BufferedReader(new FileReader(path));) { final StringBuilder text = new StringBuilder(); String line; @@ -1087,8 +1135,8 @@ protected void fetchNativeDeviceContexts( } } - final @NotNull Map serialized = - InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); + final @NotNull Map serialized = InternalSentrySdk.serializeScope(context, + (SentryAndroidOptions) options, currentScope); final @Nullable Object deviceContext = RNSentryMapConverter.convertToWritable(serialized); promise.resolve(deviceContext); } @@ -1104,9 +1152,8 @@ protected void fetchNativeLogContexts( return; } - Object contextsObj = - InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) - .get("contexts"); + Object contextsObj = InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); if (!(contextsObj instanceof Map)) { promise.resolve(null); @@ -1135,8 +1182,7 @@ protected void fetchNativeLogContexts( } public void fetchNativeSdkInfo(Promise promise) { - final @Nullable SdkVersion sdkVersion = - ScopesAdapter.getInstance().getOptions().getSdkVersion(); + final @Nullable SdkVersion sdkVersion = ScopesAdapter.getInstance().getOptions().getSdkVersion(); if (sdkVersion == null) { promise.resolve(null); } else { @@ -1154,8 +1200,7 @@ public String fetchNativePackageName() { public void getDataFromUri(String uri, Promise promise) { try { Uri contentUri = Uri.parse(uri); - try (InputStream is = - getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { + try (InputStream is = getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { if (is == null) { String msg = "File not found for uri: " + uri; logger.log(SentryLevel.ERROR, msg); @@ -1292,7 +1337,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/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..3fe172b0cf 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -57,6 +57,7 @@ export type NativeSdkOptions = Partial & { ignoreErrorsRegex?: string[] | undefined; } & { mobileReplayOptions: MobileReplayOptions | undefined; + androidProfilingOptions?: import('./options').AndroidProfilingOptions | undefined; }; interface SentryNativeWrapper { 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 4ff485a49cf6069af825070702b9b033439cfff5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 13 Jan 2026 15:11:33 +0100 Subject: [PATCH 2/5] Fixes to imports --- .../io/sentry/react/RNSentryModuleImpl.java | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) 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 fc355c84da..c744f2083a 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 @@ -36,6 +36,7 @@ import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; import io.sentry.Integration; +import io.sentry.ProfileLifecycle; import io.sentry.ScopesAdapter; import io.sentry.ScreenshotStrategyType; import io.sentry.Sentry; @@ -483,8 +484,7 @@ private SentryReplayQuality parseReplayQuality(@Nullable String qualityString) { } } - private void configureAndroidProfiling( - @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions) { + private void configureAndroidProfiling(@NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions) { if (!rnOptions.hasKey("_experiments")) { return; } @@ -516,24 +516,21 @@ private void configureAndroidProfiling( if (androidProfilingOptions.hasKey("lifecycle")) { final String lifecycle = androidProfilingOptions.getString("lifecycle"); if ("manual".equalsIgnoreCase(lifecycle)) { - options.setProfilingLifecycle( - io.sentry.android.core.SentryAndroidOptions.ProfilingLifecycle.MANUAL); - logger.log(SentryLevel.INFO, "Android UI Profiling lifecycle set to: MANUAL"); + options.setProfileLifecycle(ProfileLifecycle.MANUAL); + logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to MANUAL"); } else if ("trace".equalsIgnoreCase(lifecycle)) { - options.setProfilingLifecycle( - io.sentry.android.core.SentryAndroidOptions.ProfilingLifecycle.TRACE); - logger.log(SentryLevel.INFO, "Android UI Profiling lifecycle set to: TRACE"); + 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.setStartProfilingOnAppStart(startOnAppStart); + options.setStartProfilerOnAppStart(startOnAppStart); logger.log( SentryLevel.INFO, - String.format( - "Android UI Profiling startOnAppStart set to: %b", startOnAppStart)); + String.format("Android UI Profiling startOnAppStart set to %b", startOnAppStart)); } } From d533217112916cd13683b958eed877a90d4c87c6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 14 Jan 2026 12:02:29 +0100 Subject: [PATCH 3/5] Lint fixes --- .../io/sentry/react/RNSentryModuleImpl.java | 127 ++++++++++-------- packages/core/src/js/wrapper.ts | 4 +- 2 files changed, 74 insertions(+), 57 deletions(-) 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 c744f2083a..5a24e7d54c 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 @@ -117,8 +117,7 @@ public class RNSentryModuleImpl { private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; - @VisibleForTesting - static long lastStartTimestampMs = -1; + @VisibleForTesting static long lastStartTimestampMs = -1; // 700ms to constitute frozen frames. private static final int FROZEN_FRAME_THRESHOLD = 700; @@ -128,8 +127,7 @@ public class RNSentryModuleImpl { private static final int SCREENSHOT_TIMEOUT_SECONDS = 2; /** - * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 - * to avoid possible + * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible * lockstep sampling. More on * https://stackoverflow.com/questions/45470758/what-is-lockstep-sampling */ @@ -174,12 +172,13 @@ private ReactApplicationContext getReactApplicationContext() { } private void initFragmentInitialFrameTracking() { - final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReactFragmentLifecycleTracer( - buildInfo, emitNewFrameEvent, logger); + final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReactFragmentLifecycleTracer(buildInfo, emitNewFrameEvent, logger); final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); if (fragmentActivity != null) { - final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -187,8 +186,8 @@ private void initFragmentInitialFrameTracking() { } private void initFragmentReplayTracking() { - final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = new RNSentryReplayFragmentLifecycleTracer( - logger); + final RNSentryReplayFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReplayFragmentLifecycleTracer(logger); final @Nullable Activity currentActivity = getCurrentActivity(); if (!(currentActivity instanceof FragmentActivity)) { @@ -196,7 +195,8 @@ private void initFragmentReplayTracking() { } final @NotNull FragmentActivity fragmentActivity = (FragmentActivity) currentActivity; - final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); + final @Nullable FragmentManager supportFragmentManager = + fragmentActivity.getSupportFragmentManager(); if (supportFragmentManager != null) { supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); } @@ -226,8 +226,7 @@ protected Context getApplicationContext() { protected void getSentryAndroidOptions( @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable - SdkVersion sdkVersion = options.getSdkVersion(); + @Nullable SdkVersion sdkVersion = options.getSdkVersion(); if (sdkVersion == null) { sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); } else { @@ -329,7 +328,8 @@ protected void getSentryAndroidOptions( // Check if the replay integration is available on the classpath. It's already // kept from R8 // shrinking by sentry-android-core - final boolean isReplayAvailable = loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); + final boolean isReplayAvailable = + loadClass.isClassAvailable("io.sentry.android.replay.ReplayIntegration", logger); if (isReplayEnabled(replayOptions) && isReplayAvailable) { options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); initFragmentReplayTracking(); @@ -394,11 +394,13 @@ private boolean isReplayEnabled(SentryReplayOptions replayOptions) { } private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + final SdkVersion replaySdkVersion = + new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); @NotNull - final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(false, replaySdkVersion); + final SentryReplayOptions androidReplayOptions = + new SentryReplayOptions(false, replaySdkVersion); if (!(rnOptions.hasKey("replaysSessionSampleRate") || rnOptions.hasKey("replaysOnErrorSampleRate"))) { @@ -423,8 +425,7 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { if (!rnOptions.hasKey("mobileReplayOptions")) { return androidReplayOptions; } - @Nullable - final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); if (rnMobileReplayOptions == null) { return androidReplayOptions; } @@ -436,15 +437,17 @@ private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { !rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); - final boolean redactVectors = !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); + final boolean redactVectors = + !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); if (redactVectors) { androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg } if (rnMobileReplayOptions.hasKey("screenshotStrategy")) { final String screenshotStrategyString = rnMobileReplayOptions.getString("screenshotStrategy"); - final ScreenshotStrategyType screenshotStrategy = parseScreenshotStrategy(screenshotStrategyString); + final ScreenshotStrategyType screenshotStrategy = + parseScreenshotStrategy(screenshotStrategyString); androidReplayOptions.setScreenshotStrategy(screenshotStrategy); } @@ -484,13 +487,13 @@ private SentryReplayQuality parseReplayQuality(@Nullable String qualityString) { } } - private void configureAndroidProfiling(@NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions) { + private void configureAndroidProfiling( + @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions) { if (!rnOptions.hasKey("_experiments")) { return; } - @Nullable - final ReadableMap experiments = rnOptions.getMap("_experiments"); + @Nullable final ReadableMap experiments = rnOptions.getMap("_experiments"); if (experiments == null || !experiments.hasKey("androidProfilingOptions")) { return; } @@ -503,7 +506,8 @@ private void configureAndroidProfiling(@NotNull SentryAndroidOptions options, @N // Set profile session sample rate if (androidProfilingOptions.hasKey("profileSessionSampleRate")) { - final double profileSessionSampleRate = androidProfilingOptions.getDouble("profileSessionSampleRate"); + final double profileSessionSampleRate = + androidProfilingOptions.getDouble("profileSessionSampleRate"); options.setProfileSessionSampleRate(profileSessionSampleRate); logger.log( SentryLevel.INFO, @@ -592,10 +596,12 @@ protected void fetchNativeAppStart( return; } - WritableMap mutableMeasurement = (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); + WritableMap mutableMeasurement = + (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs(); - boolean hasFetched = lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; + boolean hasFetched = + lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; mutableMeasurement.putBoolean("has_fetched", hasFetched); if (lastStartTimestampMs < 0) { @@ -728,11 +734,12 @@ public void captureScreenshot(Promise promise) { private static byte[] takeScreenshotOnUiThread(Activity activity) { CountDownLatch doneSignal = new CountDownLatch(1); - final byte[][] bytesWrapper = { {} }; // wrapper to be able to set the value in the runnable - final Runnable runTakeScreenshot = () -> { - bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); - doneSignal.countDown(); - }; + final byte[][] bytesWrapper = {{}}; // wrapper to be able to set the value in the runnable + final Runnable runTakeScreenshot = + () -> { + bytesWrapper[0] = takeScreenshot(activity, logger, buildInfo); + doneSignal.countDown(); + }; if (UiThreadUtil.isOnUiThread()) { runTakeScreenshot.run(); @@ -752,7 +759,8 @@ private static byte[] takeScreenshotOnUiThread(Activity activity) { public void fetchViewHierarchy(Promise promise) { final @Nullable Activity activity = getCurrentActivity(); - final @Nullable ViewHierarchy viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); + final @Nullable ViewHierarchy viewHierarchy = + ViewHierarchyEventProcessor.snapshotViewHierarchy(activity, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); promise.resolve(null); @@ -760,7 +768,8 @@ public void fetchViewHierarchy(Promise promise) { } ISerializer serializer = ScopesAdapter.getInstance().getOptions().getSerializer(); - final @Nullable byte[] bytes = JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); + final @Nullable byte[] bytes = + JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); if (bytes == null) { logger.log(SentryLevel.ERROR, "Could not serialize ViewHierarchy."); promise.resolve(null); @@ -970,7 +979,8 @@ public void getNewScreenTimeToDisplay(Promise promise) { private String getProfilingTracesDirPath() { if (cacheDirPath == null) { - cacheDirPath = new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); + cacheDirPath = + new File(getReactApplicationContext().getCacheDir(), "sentry/react").getAbsolutePath(); } File profilingTraceDir = new File(cacheDirPath, "profiling_trace"); profilingTraceDir.mkdirs(); @@ -983,12 +993,13 @@ private void initializeAndroidProfiler() { } final String tracesFilesDirPath = getProfilingTracesDirPath(); - androidProfiler = new AndroidProfiler( - tracesFilesDirPath, - (int) SECONDS.toMicros(1) / profilingTracesHz, - new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), - executorService, - logger); + androidProfiler = + new AndroidProfiler( + tracesFilesDirPath, + (int) SECONDS.toMicros(1) / profilingTracesHz, + new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), + executorService, + logger); } public WritableMap startProfiling(boolean platformProfilers) { @@ -1022,8 +1033,9 @@ public WritableMap stopProfiling() { } HermesSamplingProfiler.disable(); - output = File.createTempFile( - "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); + output = + File.createTempFile( + "sampling-profiler-trace", ".cpuprofile", reactApplicationContext.getCacheDir()); if (isDebug) { logger.log(SentryLevel.INFO, "Profile saved to: " + output.getAbsolutePath()); } @@ -1033,8 +1045,10 @@ public WritableMap stopProfiling() { if (end != null) { WritableMap androidProfile = new WritableNativeMap(); - byte[] androidProfileBytes = FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); - String base64AndroidProfile = Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); + byte[] androidProfileBytes = + FileUtils.readBytesFromFile(end.traceFile.getPath(), maxTraceFileSize); + String base64AndroidProfile = + Base64.encodeToString(androidProfileBytes, NO_WRAP | NO_PADDING); androidProfile.putString("sampled_profile", base64AndroidProfile); androidProfile.putInt("android_api_level", buildInfo.getSdkInfoVersion()); @@ -1063,8 +1077,8 @@ public WritableMap stopProfiling() { return proguardUuid; } isProguardDebugMetaLoaded = true; - final @Nullable List debugMetaList = new AssetsDebugMetaLoader(this.getReactApplicationContext(), - logger).loadDebugMeta(); + final @Nullable List debugMetaList = + new AssetsDebugMetaLoader(this.getReactApplicationContext(), logger).loadDebugMeta(); if (debugMetaList == null) { return null; } @@ -1082,7 +1096,7 @@ public WritableMap stopProfiling() { } private String readStringFromFile(File path) throws IOException { - try (BufferedReader br = new BufferedReader(new FileReader(path));) { + try (BufferedReader br = new BufferedReader(new FileReader(path)); ) { final StringBuilder text = new StringBuilder(); String line; @@ -1132,8 +1146,8 @@ protected void fetchNativeDeviceContexts( } } - final @NotNull Map serialized = InternalSentrySdk.serializeScope(context, - (SentryAndroidOptions) options, currentScope); + final @NotNull Map serialized = + InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); final @Nullable Object deviceContext = RNSentryMapConverter.convertToWritable(serialized); promise.resolve(deviceContext); } @@ -1149,8 +1163,9 @@ protected void fetchNativeLogContexts( return; } - Object contextsObj = InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) - .get("contexts"); + Object contextsObj = + InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); if (!(contextsObj instanceof Map)) { promise.resolve(null); @@ -1179,7 +1194,8 @@ protected void fetchNativeLogContexts( } public void fetchNativeSdkInfo(Promise promise) { - final @Nullable SdkVersion sdkVersion = ScopesAdapter.getInstance().getOptions().getSdkVersion(); + final @Nullable SdkVersion sdkVersion = + ScopesAdapter.getInstance().getOptions().getSdkVersion(); if (sdkVersion == null) { promise.resolve(null); } else { @@ -1197,7 +1213,8 @@ public String fetchNativePackageName() { public void getDataFromUri(String uri, Promise promise) { try { Uri contentUri = Uri.parse(uri); - try (InputStream is = getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { + try (InputStream is = + getReactApplicationContext().getContentResolver().openInputStream(contentUri)) { if (is == null) { String msg = "File not found for uri: " + uri; logger.log(SentryLevel.ERROR, msg); diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 3fe172b0cf..2a00801fe7 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,7 +57,7 @@ export type NativeSdkOptions = Partial & { ignoreErrorsRegex?: string[] | undefined; } & { mobileReplayOptions: MobileReplayOptions | undefined; - androidProfilingOptions?: import('./options').AndroidProfilingOptions | undefined; + androidProfilingOptions?: AndroidProfilingOptions | undefined; }; interface SentryNativeWrapper { From d559e1142ddba76d9b48461b311a93d166220ed5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 15 Jan 2026 12:11:36 +0100 Subject: [PATCH 4/5] Fixes --- packages/core/src/js/wrapper.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 2a00801fe7..4089bc5c75 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -287,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; From 9f4a3199862e7f2e7964ecc345352a379009253c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 15 Jan 2026 12:38:03 +0100 Subject: [PATCH 5/5] Changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 745822869c..e98ff95383 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 for missing `replay_id` from metrics ([#5483](https://github.com/getsentry/sentry-react-native/pull/5483))