diff --git a/CHANGELOG.md b/CHANGELOG.md index c8604c6fc9..e11259edfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ ### Features +- Extends the experimental support of UI profiling to iOS ([#5611](https://github.com/getsentry/sentry-react-native/pull/5611)) + ```js + Sentry.init({ + _experiments: { + profilingOptions: { + profileSessionSampleRate: 1.0, + lifecycle: 'trace', // or 'manual' + startOnAppStart: true, + }, + }, + }); + ``` + - Note that the `androidProfilingOptions` key is now deprecated, and `profilingOptions` should be used instead - Add performance tracking for Expo Router route prefetching ([#5606](https://github.com/getsentry/sentry-react-native/pull/5606)) - New `wrapExpoRouter` utility to instrument manual `prefetch()` calls with performance spans - New `enablePrefetchTracking` option for `reactNavigationIntegration` to automatically track PRELOAD actions 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 11a4b40c53..a2229a354b 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 @@ -494,47 +494,44 @@ private void configureAndroidProfiling( } @Nullable final ReadableMap experiments = rnOptions.getMap("_experiments"); - if (experiments == null || !experiments.hasKey("androidProfilingOptions")) { + if (experiments == null || !experiments.hasKey("profilingOptions")) { return; } - @Nullable - final ReadableMap androidProfilingOptions = experiments.getMap("androidProfilingOptions"); - if (androidProfilingOptions == null) { + @Nullable final ReadableMap profilingOptions = experiments.getMap("profilingOptions"); + if (profilingOptions == null) { return; } // Set profile session sample rate - if (androidProfilingOptions.hasKey("profileSessionSampleRate")) { + if (profilingOptions.hasKey("profileSessionSampleRate")) { final double profileSessionSampleRate = - androidProfilingOptions.getDouble("profileSessionSampleRate"); + profilingOptions.getDouble("profileSessionSampleRate"); options.setProfileSessionSampleRate(profileSessionSampleRate); logger.log( SentryLevel.INFO, String.format( - "Android UI Profiling profileSessionSampleRate set to: %.2f", - profileSessionSampleRate)); + "UI Profiling profileSessionSampleRate set to: %.2f", profileSessionSampleRate)); } // Set profiling lifecycle mode - if (androidProfilingOptions.hasKey("lifecycle")) { - final String lifecycle = androidProfilingOptions.getString("lifecycle"); + if (profilingOptions.hasKey("lifecycle")) { + final String lifecycle = profilingOptions.getString("lifecycle"); if ("manual".equalsIgnoreCase(lifecycle)) { options.setProfileLifecycle(ProfileLifecycle.MANUAL); - logger.log(SentryLevel.INFO, "Android UI Profile Lifecycle set to MANUAL"); + logger.log(SentryLevel.INFO, "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"); + logger.log(SentryLevel.INFO, "UI Profile Lifecycle set to TRACE"); } } // Set start on app start - if (androidProfilingOptions.hasKey("startOnAppStart")) { - final boolean startOnAppStart = androidProfilingOptions.getBoolean("startOnAppStart"); + if (profilingOptions.hasKey("startOnAppStart")) { + final boolean startOnAppStart = profilingOptions.getBoolean("startOnAppStart"); options.setStartProfilerOnAppStart(startOnAppStart); logger.log( - SentryLevel.INFO, - String.format("Android UI Profiling startOnAppStart set to %b", startOnAppStart)); + SentryLevel.INFO, String.format("Profiling startOnAppStart set to %b", startOnAppStart)); } } diff --git a/packages/core/ios/RNSentryExperimentalOptions.h b/packages/core/ios/RNSentryExperimentalOptions.h index ec0501cb05..eece5af374 100644 --- a/packages/core/ios/RNSentryExperimentalOptions.h +++ b/packages/core/ios/RNSentryExperimentalOptions.h @@ -37,6 +37,14 @@ NS_ASSUME_NONNULL_BEGIN + (void)setEnableSessionReplayInUnreliableEnvironment:(BOOL)enabled sentryOptions:(SentryOptions *)sentryOptions; +/** + * Configures iOS UI profiling options on SentryOptions + * @param profilingOptions Dictionary containing profiling configuration + * @param sentryOptions The SentryOptions instance to configure + */ ++ (void)configureProfilingWithOptions:(NSDictionary *)profilingOptions + sentryOptions:(SentryOptions *)sentryOptions; + @end NS_ASSUME_NONNULL_END diff --git a/packages/core/ios/RNSentryExperimentalOptions.m b/packages/core/ios/RNSentryExperimentalOptions.m index 7e0974e527..084ed36309 100644 --- a/packages/core/ios/RNSentryExperimentalOptions.m +++ b/packages/core/ios/RNSentryExperimentalOptions.m @@ -1,4 +1,5 @@ #import "RNSentryExperimentalOptions.h" +#import @import Sentry; @implementation RNSentryExperimentalOptions @@ -36,4 +37,45 @@ + (void)setEnableSessionReplayInUnreliableEnvironment:(BOOL)enabled sentryOptions.experimental.enableSessionReplayInUnreliableEnvironment = enabled; } ++ (void)configureProfilingWithOptions:(NSDictionary *)profilingOptions + sentryOptions:(SentryOptions *)sentryOptions +{ +#if SENTRY_TARGET_PROFILING_SUPPORTED + if (sentryOptions == nil || profilingOptions == nil) { + return; + } + + sentryOptions.configureProfiling = ^(SentryProfileOptions *_Nonnull profiling) { + // Set session sample rate + id profileSessionSampleRate = profilingOptions[@"profileSessionSampleRate"]; + if (profileSessionSampleRate != nil && + [profileSessionSampleRate isKindOfClass:[NSNumber class]]) { + profiling.sessionSampleRate = [profileSessionSampleRate floatValue]; + NSLog(@"Sentry: UI Profiling sessionSampleRate set to: %.2f", + profiling.sessionSampleRate); + } + + // Set lifecycle mode + NSString *lifecycle = profilingOptions[@"lifecycle"]; + if ([lifecycle isKindOfClass:[NSString class]]) { + if ([lifecycle caseInsensitiveCompare:@"manual"] == NSOrderedSame) { + profiling.lifecycle = SentryProfileLifecycleManual; + NSLog(@"Sentry: UI Profiling Lifecycle set to MANUAL"); + } else if ([lifecycle caseInsensitiveCompare:@"trace"] == NSOrderedSame) { + profiling.lifecycle = SentryProfileLifecycleTrace; + NSLog(@"Sentry: UI Profiling Lifecycle set to TRACE"); + } + } + + // Set profile app starts + id startOnAppStart = profilingOptions[@"startOnAppStart"]; + if (startOnAppStart != nil && [startOnAppStart isKindOfClass:[NSNumber class]]) { + profiling.profileAppStarts = [startOnAppStart boolValue]; + NSLog(@"Sentry: UI Profiling profileAppStarts set to %@", + profiling.profileAppStarts ? @"YES" : @"NO"); + } + }; +#endif +} + @end diff --git a/packages/core/ios/SentrySDKWrapper.m b/packages/core/ios/SentrySDKWrapper.m index c11782d5ad..7817e0855a 100644 --- a/packages/core/ios/SentrySDKWrapper.m +++ b/packages/core/ios/SentrySDKWrapper.m @@ -106,6 +106,13 @@ + (SentryOptions *)createOptionsWithDictionary:(NSDictionary *)options [experiments[@"enableUnhandledCPPExceptionsV2"] boolValue]; [RNSentryExperimentalOptions setEnableUnhandledCPPExceptionsV2:enableUnhandledCPPExceptions sentryOptions:sentryOptions]; + + // Configure iOS UI Profiling + NSDictionary *profilingOptions = experiments[@"profilingOptions"]; + if (profilingOptions != nil && [profilingOptions isKindOfClass:[NSDictionary class]]) { + [RNSentryExperimentalOptions configureProfilingWithOptions:profilingOptions + sentryOptions:sentryOptions]; + } } if (isSessionReplayEnabled) { diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index 699c94cd0e..74a834090a 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -224,7 +224,8 @@ 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, + profilingOptions: + this._options._experiments?.profilingOptions ?? this._options._experiments?.androidProfilingOptions, }) .then( (result: boolean) => { diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index 859e876839..20f2e7207d 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -294,16 +294,26 @@ export interface BaseReactNativeOptions { */ enableUnhandledCPPExceptionsV2?: boolean; + /** + * Configuration options for UI profiling. + * It 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 + */ + profilingOptions?: ProfilingOptions; + /** * Configuration options for Android UI profiling. - * UI profiling supports two modes: `manual` and `trace`. + * It 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 + * @deprecated Use `profilingOptions` instead. This option will be removed in the next major version. */ - androidProfilingOptions?: AndroidProfilingOptions; + androidProfilingOptions?: ProfilingOptions; }; /** @@ -342,19 +352,18 @@ export interface BaseReactNativeOptions { export type SentryReplayQuality = 'low' | 'medium' | 'high'; /** - * Android UI profiling lifecycle modes. + * 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'; +export type ProfilingLifecycle = 'trace' | 'manual'; /** - * Configuration options for Android UI profiling. + * Configuration options for UI profiling. * * @experimental - * @platform android */ -export interface AndroidProfilingOptions { +export interface ProfilingOptions { /** * Sample rate for profiling sessions. * This is evaluated once per session and determines if profiling should be enabled for that session. @@ -369,9 +378,9 @@ export interface AndroidProfilingOptions { * - `trace`: Profiler runs while there is at least one active sampled span * - `manual`: Profiler is controlled manually via Sentry.profiler.startProfiler/stopProfiler * - * @default 'trace' + * @default 'manual' */ - lifecycle?: AndroidProfilingLifecycle; + lifecycle?: ProfilingLifecycle; /** * Enable profiling on app start. diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 55fcb3a2e8..35d686e39d 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 { AndroidProfilingOptions, ReactNativeClientOptions } from './options'; +import type { ProfilingOptions, 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,9 @@ export type NativeSdkOptions = Partial & { ignoreErrorsRegex?: string[] | undefined; } & { mobileReplayOptions: MobileReplayOptions | undefined; - androidProfilingOptions?: AndroidProfilingOptions | undefined; + profilingOptions?: ProfilingOptions | undefined; + /** @deprecated Use `profilingOptions` instead. */ + androidProfilingOptions?: ProfilingOptions | undefined; }; interface SentryNativeWrapper { @@ -289,16 +291,19 @@ export const NATIVE: SentryNativeWrapper = { integrations, ignoreErrors, logsOrigin, + profilingOptions, androidProfilingOptions, ...filteredOptions } = options; /* eslint-enable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */ - // Move androidProfilingOptions into _experiments - if (androidProfilingOptions) { + // Move profilingOptions into _experiments + // Support deprecated androidProfilingOptions for backwards compatibility + const resolvedProfilingOptions = profilingOptions ?? androidProfilingOptions; + if (resolvedProfilingOptions) { filteredOptions._experiments = { ...filteredOptions._experiments, - androidProfilingOptions, + profilingOptions: resolvedProfilingOptions, }; } diff --git a/packages/core/test/androidProfiling.test.ts b/packages/core/test/profiling.test.ts similarity index 60% rename from packages/core/test/androidProfiling.test.ts rename to packages/core/test/profiling.test.ts index 68de0f1ca9..8e16df0899 100644 --- a/packages/core/test/androidProfiling.test.ts +++ b/packages/core/test/profiling.test.ts @@ -54,14 +54,14 @@ jest.mock('react-native', () => { const RNSentry = require('react-native').NativeModules.RNSentry as Spec; -describe('Android UI Profiling Options', () => { +describe('UI Profiling Options', () => { beforeEach(() => { NATIVE.platform = 'android'; NATIVE.enableNative = true; jest.clearAllMocks(); }); - it('passes androidProfilingOptions to native SDK', async () => { + it('passes profilingOptions to native SDK', async () => { await NATIVE.initNativeSdk({ dsn: 'https://example@sentry.io/123', enableNative: true, @@ -69,7 +69,7 @@ describe('Android UI Profiling Options', () => { devServerUrl: undefined, defaultSidecarUrl: undefined, mobileReplayOptions: undefined, - androidProfilingOptions: { + profilingOptions: { profileSessionSampleRate: 0.5, lifecycle: 'trace', startOnAppStart: true, @@ -79,7 +79,7 @@ describe('Android UI Profiling Options', () => { expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( expect.objectContaining({ _experiments: expect.objectContaining({ - androidProfilingOptions: { + profilingOptions: { profileSessionSampleRate: 0.5, lifecycle: 'trace', startOnAppStart: true, @@ -89,7 +89,7 @@ describe('Android UI Profiling Options', () => { ); }); - it('passes androidProfilingOptions with manual lifecycle', async () => { + it('passes profilingOptions with manual lifecycle', async () => { await NATIVE.initNativeSdk({ dsn: 'https://example@sentry.io/123', enableNative: true, @@ -97,7 +97,7 @@ describe('Android UI Profiling Options', () => { devServerUrl: undefined, defaultSidecarUrl: undefined, mobileReplayOptions: undefined, - androidProfilingOptions: { + profilingOptions: { profileSessionSampleRate: 1.0, lifecycle: 'manual', startOnAppStart: false, @@ -107,7 +107,7 @@ describe('Android UI Profiling Options', () => { expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( expect.objectContaining({ _experiments: expect.objectContaining({ - androidProfilingOptions: { + profilingOptions: { profileSessionSampleRate: 1.0, lifecycle: 'manual', startOnAppStart: false, @@ -117,7 +117,7 @@ describe('Android UI Profiling Options', () => { ); }); - it('does not pass androidProfilingOptions when undefined', async () => { + it('does not pass profilingOptions when undefined', async () => { await NATIVE.initNativeSdk({ dsn: 'https://example@sentry.io/123', enableNative: true, @@ -125,14 +125,14 @@ describe('Android UI Profiling Options', () => { devServerUrl: undefined, defaultSidecarUrl: undefined, mobileReplayOptions: undefined, - androidProfilingOptions: undefined, + profilingOptions: undefined, }); const callArgs = (RNSentry.initNativeSdk as jest.Mock).mock.calls[0][0]; - expect(callArgs._experiments?.androidProfilingOptions).toBeUndefined(); + expect(callArgs._experiments?.profilingOptions).toBeUndefined(); }); - it('handles partial androidProfilingOptions', async () => { + it('handles partial profilingOptions', async () => { await NATIVE.initNativeSdk({ dsn: 'https://example@sentry.io/123', enableNative: true, @@ -140,7 +140,7 @@ describe('Android UI Profiling Options', () => { devServerUrl: undefined, defaultSidecarUrl: undefined, mobileReplayOptions: undefined, - androidProfilingOptions: { + profilingOptions: { profileSessionSampleRate: 0.3, // lifecycle and startOnAppStart not provided }, @@ -149,11 +149,71 @@ describe('Android UI Profiling Options', () => { expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( expect.objectContaining({ _experiments: expect.objectContaining({ - androidProfilingOptions: { + profilingOptions: { profileSessionSampleRate: 0.3, }, }), }), ); }); + + describe('deprecated androidProfilingOptions', () => { + it('passes deprecated androidProfilingOptions as profilingOptions 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.7, + lifecycle: 'trace', + startOnAppStart: true, + }, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + _experiments: expect.objectContaining({ + profilingOptions: { + profileSessionSampleRate: 0.7, + lifecycle: 'trace', + startOnAppStart: true, + }, + }), + }), + ); + }); + + it('prefers profilingOptions over deprecated androidProfilingOptions', async () => { + await NATIVE.initNativeSdk({ + dsn: 'https://example@sentry.io/123', + enableNative: true, + autoInitializeNativeSdk: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + profilingOptions: { + profileSessionSampleRate: 0.5, + lifecycle: 'manual', + }, + androidProfilingOptions: { + profileSessionSampleRate: 0.9, + lifecycle: 'trace', + }, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + _experiments: expect.objectContaining({ + profilingOptions: { + profileSessionSampleRate: 0.5, + lifecycle: 'manual', + }, + }), + }), + ); + }); + }); }); diff --git a/samples/expo/app.json b/samples/expo/app.json index c3eb7ef323..f0ce75b461 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -14,9 +14,7 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" }, - "assetBundlePatterns": [ - "**/*" - ], + "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", @@ -90,4 +88,4 @@ "url": "https://u.expo.dev/00000000-0000-0000-0000-000000000000" } } -} \ No newline at end of file +}