diff --git a/CHANGELOG.md b/CHANGELOG.md index f5dbb6ecae..9f5020e792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixes - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) +- Start and stop the native UI profiler for `_experiments.profilingOptions` in both the `trace` and `manual` lifecycles, and export `Sentry.profiler` ([#6916](https://github.com/getsentry/sentry-react-native/pull/6916)) + - Before, the profiler ran only for the app start profile, because native tracing is off in React Native and no API controlled the profiler from JS. ## 8.31.0 diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryProfilingOptionsTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryProfilingOptionsTest.kt index babb40558d..2c56a2b774 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryProfilingOptionsTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryProfilingOptionsTest.kt @@ -16,7 +16,6 @@ import org.junit.runners.JUnit4 import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.mock -import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify @@ -38,7 +37,7 @@ class RNSentryProfilingOptionsTest { } @Test - fun `profilingOptions wires sessionSampleRate, trace lifecycle, and startOnAppStart`() { + fun `profilingOptions wires sessionSampleRate, startOnAppStart, and a MANUAL native lifecycle`() { val rnOptions = JavaOnlyMap.of( "_experiments", @@ -59,42 +58,8 @@ class RNSentryProfilingOptionsTest { RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) assertEquals(1.0, options.profileSessionSampleRate!!, 0.0) - assertEquals(ProfileLifecycle.TRACE, options.profileLifecycle) - assertTrue(options.isStartProfilerOnAppStart) - } - - @Test - fun `profilingOptions lifecycle manual maps to ProfileLifecycle MANUAL`() { - val rnOptions = - JavaOnlyMap.of( - "_experiments", - JavaOnlyMap.of( - "profilingOptions", - JavaOnlyMap.of("lifecycle", "manual"), - ), - ) - val options = SentryAndroidOptions() - - RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) - assertEquals(ProfileLifecycle.MANUAL, options.profileLifecycle) - } - - @Test - fun `profilingOptions lifecycle is case-insensitive`() { - val rnOptions = - JavaOnlyMap.of( - "_experiments", - JavaOnlyMap.of( - "profilingOptions", - JavaOnlyMap.of("lifecycle", "TRACE"), - ), - ) - val options = SentryAndroidOptions() - - RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) - - assertEquals(ProfileLifecycle.TRACE, options.profileLifecycle) + assertTrue(options.isStartProfilerOnAppStart) } @Test @@ -172,24 +137,6 @@ class RNSentryProfilingOptionsTest { verify(logger, times(1)).log(eq(SentryLevel.WARNING), anyString()) } - @Test - fun `non-string lifecycle logs a warning and is ignored`() { - val rnOptions = - JavaOnlyMap.of( - "_experiments", - JavaOnlyMap.of( - "profilingOptions", - JavaOnlyMap.of("lifecycle", 1), - ), - ) - val options = SentryAndroidOptions() - - RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) - - assertEquals(ProfileLifecycle.MANUAL, options.profileLifecycle) - verify(logger, times(1)).log(eq(SentryLevel.WARNING), anyString()) - } - @Test fun `non-boolean startOnAppStart logs a warning and is ignored`() { val rnOptions = @@ -207,24 +154,4 @@ class RNSentryProfilingOptionsTest { assertFalse(options.isStartProfilerOnAppStart) verify(logger, times(1)).log(eq(SentryLevel.WARNING), anyString()) } - - @Test - fun `unknown lifecycle string is silently ignored without warning`() { - val rnOptions = - JavaOnlyMap.of( - "_experiments", - JavaOnlyMap.of( - "profilingOptions", - JavaOnlyMap.of("lifecycle", "not-a-real-mode"), - ), - ) - val options = SentryAndroidOptions() - - RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) - - // Implementation only logs a warning for type mismatches, not unknown string values. - // Lifecycle stays at the default. - assertEquals(ProfileLifecycle.MANUAL, options.profileLifecycle) - verify(logger, never()).log(eq(SentryLevel.WARNING), anyString()) - } } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m index a5aa18533a..bbc7b29ad6 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m @@ -649,7 +649,7 @@ - (void)testStartWithDictionaryInstallsConfigureProfilingFromExperimentsProfilin SentryProfileOptions *probe = [[SentryProfileOptions alloc] init]; actualOptions.configureProfiling(probe); XCTAssertEqual(probe.sessionSampleRate, 1.0f); - XCTAssertEqual(probe.lifecycle, SentryProfileLifecycleTrace); + XCTAssertEqual(probe.lifecycle, SentryProfileLifecycleManual); XCTAssertTrue(probe.profileAppStarts); } @@ -678,7 +678,7 @@ - (void)testStartCreateOptionsWithDictionaryProfilingOptionsInstallsConfigurePro SentryProfileOptions *probe = [[SentryProfileOptions alloc] init]; actualOptions.configureProfiling(probe); XCTAssertEqual(probe.sessionSampleRate, 1.0f); - XCTAssertEqual(probe.lifecycle, SentryProfileLifecycleTrace); + XCTAssertEqual(probe.lifecycle, SentryProfileLifecycleManual); XCTAssertTrue(probe.profileAppStarts); } 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 d100f388f1..2b313e8050 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 @@ -11,6 +11,7 @@ import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.net.Uri; +import android.os.Process; import android.util.SparseIntArray; import androidx.annotation.VisibleForTesting; import androidx.core.app.FrameMetricsAggregator; @@ -556,6 +557,32 @@ public void flushReplay(Promise promise) { } } + public void startProfiler(Promise promise) { + try { + Sentry.startProfiler(); + final SentryId profilerId = + Sentry.getCurrentScopes().getOptions().getContinuousProfiler().getProfilerId(); + promise.resolve(SentryId.EMPTY_ID.equals(profilerId) ? null : profilerId.toString()); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to start the profiler", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public String getCurrentThreadId() { + return String.valueOf(Process.myTid()); + } + + public void stopProfiler(Promise promise) { + try { + Sentry.stopProfiler(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to stop the profiler", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + public void registerReplayTraceId(String traceId) { // Forward a trace id seen on a sent event to Session Replay so it lands in // the current segment's `trace_ids`. The controller owns dedup, the 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 de3ec607e0..0b80ef4d42 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 @@ -318,22 +318,8 @@ private static void configureAndroidProfiling( } } - // Set profiling lifecycle mode - if (profilingOptions.hasKey("lifecycle")) { - if (profilingOptions.getType("lifecycle") == ReadableType.String) { - final String lifecycle = profilingOptions.getString("lifecycle"); - if ("manual".equalsIgnoreCase(lifecycle)) { - options.setProfileLifecycle(ProfileLifecycle.MANUAL); - logger.log(SentryLevel.INFO, "UI Profile Lifecycle set to MANUAL"); - } else if ("trace".equalsIgnoreCase(lifecycle)) { - options.setProfileLifecycle(ProfileLifecycle.TRACE); - logger.log(SentryLevel.INFO, "UI Profile Lifecycle set to TRACE"); - } - } else { - logger.log( - SentryLevel.WARNING, "UI Profiling lifecycle must be a string, ignoring invalid value"); - } - } + // Native tracing is off, so JS drives both lifecycles through startProfiler/stopProfiler. + options.setProfileLifecycle(ProfileLifecycle.MANUAL); // Set start on app start if (profilingOptions.hasKey("startOnAppStart")) { diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index af4e1decf5..d0663166c5 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -234,6 +234,21 @@ public void flushReplay(Promise promise) { this.impl.flushReplay(promise); } + @Override + public void startProfiler(Promise promise) { + this.impl.startProfiler(promise); + } + + @Override + public String getCurrentThreadId() { + return this.impl.getCurrentThreadId(); + } + + @Override + public void stopProfiler(Promise promise) { + this.impl.stopProfiler(promise); + } + @Override public void registerReplayTraceId(String traceId) { this.impl.registerReplayTraceId(traceId); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 31c1c8fa69..d26f42b2c4 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -234,6 +234,21 @@ public void flushReplay(Promise promise) { this.impl.flushReplay(promise); } + @ReactMethod + public void startProfiler(Promise promise) { + this.impl.startProfiler(promise); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public String getCurrentThreadId() { + return this.impl.getCurrentThreadId(); + } + + @ReactMethod + public void stopProfiler(Promise promise) { + this.impl.stopProfiler(promise); + } + @ReactMethod public void registerReplayTraceId(String traceId) { this.impl.registerReplayTraceId(traceId); diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index f666d3121e..0da0e4170f 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -76,6 +76,7 @@ import { OpenAiClient } from '@sentry/core'; import { OpenAiOptions } from '@sentry/core'; import type { Options } from '@sentry/core'; import { Profiler } from '@sentry/react'; +import { profiler } from '@sentry/core'; import * as React_2 from 'react'; import { replayIntegration } from '@sentry/react'; import type { ReplayRecordingMode } from '@sentry/core'; @@ -826,6 +827,8 @@ export const primitiveTagIntegration: () => Integration; export { Profiler } +export { profiler } + // @public export type ProfilingLifecycle = 'trace' | 'manual'; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 56398156d9..10bbd3e076 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -1,6 +1,7 @@ #import "RNSentry.h" #import "RNSentryTimeToDisplay.h" #import +#import #if __has_include() # import @@ -1216,6 +1217,37 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path #endif } +// sentry-cocoa has no public API for the current profiler id, so this resolves nil. +RCT_EXPORT_METHOD( + startProfiler : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ + @try { + [SentrySDKWrapper startProfiler]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call startProfiler: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +} + +// sentry-cocoa profiles identify threads by their Mach thread port. +RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentThreadId) +{ + return [NSString stringWithFormat:@"%u", pthread_mach_thread_np(pthread_self())]; +} + +RCT_EXPORT_METHOD( + stopProfiler : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +{ + @try { + [SentrySDKWrapper stopProfiler]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call stopProfiler: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +} + RCT_EXPORT_METHOD( crashedLastRun : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { diff --git a/packages/core/ios/RNSentryExperimentalOptions.m b/packages/core/ios/RNSentryExperimentalOptions.m index c53576254d..fbc4142a76 100644 --- a/packages/core/ios/RNSentryExperimentalOptions.m +++ b/packages/core/ios/RNSentryExperimentalOptions.m @@ -46,17 +46,8 @@ + (void)configureProfilingWithOptions:(NSDictionary *)profilingOptions 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"); - } - } + // Native tracing is off, so JS drives both lifecycles through startProfiler/stopProfiler. + profiling.lifecycle = SentryProfileLifecycleManual; // Set profile app starts id startOnAppStart = profilingOptions[@"startOnAppStart"]; diff --git a/packages/core/ios/SentrySDKWrapper.h b/packages/core/ios/SentrySDKWrapper.h index 5de5118042..0c1737e553 100644 --- a/packages/core/ios/SentrySDKWrapper.h +++ b/packages/core/ios/SentrySDKWrapper.h @@ -16,6 +16,10 @@ + (void)resumeAppHangTracking; ++ (void)startProfiler; + ++ (void)stopProfiler; + + (BOOL)debug; + (NSString *)releaseName; diff --git a/packages/core/ios/SentrySDKWrapper.m b/packages/core/ios/SentrySDKWrapper.m index c8452c6c05..9f7d770e6b 100644 --- a/packages/core/ios/SentrySDKWrapper.m +++ b/packages/core/ios/SentrySDKWrapper.m @@ -1,5 +1,6 @@ #import "SentrySDKWrapper.h" #import "RNSentrySwiftBridge.h" +#import @import Sentry; @implementation SentrySDKWrapper @@ -29,6 +30,20 @@ + (void)resumeAppHangTracking [SentrySDK resumeAppHangTracking]; } ++ (void)startProfiler +{ +#if SENTRY_TARGET_PROFILING_SUPPORTED + [SentrySDK startProfiler]; +#endif +} + ++ (void)stopProfiler +{ +#if SENTRY_TARGET_PROFILING_SUPPORTED + [SentrySDK stopProfiler]; +#endif +} + + (void)configureScope:(void (^)(SentryScope *scope))callback { [SentrySDK configureScope:callback]; diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 7c29e88e24..2a5be08dc5 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -50,6 +50,9 @@ export interface Spec extends TurboModule { androidProfile?: UnsafeObject; error?: string; }; + startProfiler(): Promise; + stopProfiler(): Promise; + getCurrentThreadId(): string | undefined | null; fetchNativePackageName(): string | undefined | null; fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null; initNativeReactNavigationNewFrameTracking(): Promise; diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 6581a17422..e65a80e33b 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -57,6 +57,7 @@ export { instrumentStateGraph, instrumentStateGraphCompile, addConsoleInstrumentationFilter, + profiler, } from '@sentry/core'; /** @deprecated Use `instrumentStateGraph` instead. `instrumentLangGraph` will be removed in a future major version. */ diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 2d380ac928..cf08acbd1e 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -5,6 +5,7 @@ import { browserSessionIntegration, consoleLoggingIntegration } from '@sentry/br import type { ReactNativeClientOptions } from '../options'; +import { uiProfilingIntegration } from '../profiling/uiProfilingIntegration'; import { expoRouterIntegration, reactNativeTracingIntegration } from '../tracing'; import { notWeb } from '../utils/environment'; import { @@ -114,6 +115,10 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ if (typeof options.profilesSampleRate === 'number') { integrations.push(hermesProfilingIntegration()); } + const profilingOptions = options._experiments?.profilingOptions ?? options._experiments?.androidProfilingOptions; + if (profilingOptions) { + integrations.push(uiProfilingIntegration(profilingOptions)); + } } // hasTracingEnabled from `@sentry/core` only check if tracesSampler or tracesSampleRate keys are present diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts new file mode 100644 index 0000000000..9dea2b5683 --- /dev/null +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -0,0 +1,195 @@ +import type { Event, Integration, Span } from '@sentry/core'; + +import { debug, getRootSpan, hasSpansEnabled, spanIsSampled, spanToJSON } from '@sentry/core'; + +import type { ReactNativeClient } from '../client'; +import type { ProfilingOptions } from '../options'; + +import { NATIVE } from '../wrapper'; + +// `Sentry.profiler` from `@sentry/core` looks the integration up by this name. +const INTEGRATION_NAME = 'ProfilingIntegration'; + +const SPAN_THREAD_ID = 'thread.id'; + +/** + * The native SDKs ignore a start call that comes before a requested stop takes effect at the end + * of the current 60 second chunk. Waiting one chunk before the stop call keeps back-to-back root + * spans in one profile. + */ +export const TRACE_STOP_DELAY_MS = 60_000; + +interface UIProfilingIntegration extends Integration { + _profiler: { + start: () => void; + stop: () => void; + }; +} + +interface ProfileSession { + profilerId: Promise; +} + +/** + * Starts and stops the native UI profiler from JS. + * + * Native tracing is off in React Native, so the native SDKs cannot follow the `trace` lifecycle. + * They run in manual mode, and this integration decides when the profiler runs. + */ +export const uiProfilingIntegration = ({ + lifecycle = 'manual', + startOnAppStart = false, +}: ProfilingOptions): UIProfilingIntegration => { + const activeRootSpanIds = new Set(); + const sessionByRootSpanId = new Map(); + let session: ProfileSession | undefined; + let stopTimeout: ReturnType | undefined; + let isNativeReady = false; + let isManualRequested = lifecycle === 'manual' && startOnAppStart; + let isAppStartPending = lifecycle === 'trace' && startOnAppStart; + let jsThreadId: string | null = null; + let resolveAppStartSession: ((profilerId: string | null) => void) | undefined; + + const shouldRun = (): boolean => + lifecycle === 'trace' ? isAppStartPending || activeRootSpanIds.size > 0 : isManualRequested; + + const clearStopTimeout = (): void => { + clearTimeout(stopTimeout); + stopTimeout = undefined; + }; + + const stopNativeProfiler = (): void => { + clearStopTimeout(); + session = undefined; + NATIVE.stopProfiler().then(undefined, () => {}); + }; + + const syncNativeProfiler = (): void => { + if (!isNativeReady) { + return; + } + + if (shouldRun()) { + clearStopTimeout(); + if (!session) { + const newSession: ProfileSession = { profilerId: NATIVE.startProfiler() }; + session = newSession; + activeRootSpanIds.forEach(spanId => { + // A root span that started under an earlier session keeps that profile. + if (!sessionByRootSpanId.has(spanId)) { + sessionByRootSpanId.set(spanId, newSession); + } + }); + } + } else if (session && !stopTimeout) { + if (lifecycle === 'trace') { + stopTimeout = setTimeout(stopNativeProfiler, TRACE_STOP_DELAY_MS); + } else { + stopNativeProfiler(); + } + } + }; + + const onSpanStart = (span: Span): void => { + // Sentry shows the profile of this thread for the span. + if (jsThreadId && spanToJSON(span).data[SPAN_THREAD_ID] === undefined) { + span.setAttribute(SPAN_THREAD_ID, jsThreadId); + } + if (span !== getRootSpan(span) || !spanIsSampled(span)) { + return; + } + const { spanId } = span.spanContext(); + activeRootSpanIds.add(spanId); + if (session) { + sessionByRootSpanId.set(spanId, session); + } + syncNativeProfiler(); + }; + + const onSpanEnd = (span: Span): void => { + // An unsampled root span also ends the app start profile, because it sends no transaction to profile. + if (!activeRootSpanIds.delete(span.spanContext().spanId) && span !== getRootSpan(span)) { + return; + } + isAppStartPending = false; + syncNativeProfiler(); + }; + + const setManualRequested = (requested: boolean): void => { + if (lifecycle === 'trace') { + debug.warn('[Profiling] `lifecycle` is set to "trace". Manual profiler calls are ignored.'); + return; + } + isManualRequested = requested; + syncNativeProfiler(); + }; + + return { + name: INTEGRATION_NAME, + setup(client: ReactNativeClient): void { + const tracingEnabled = hasSpansEnabled(client.getOptions()); + if (tracingEnabled) { + jsThreadId = NATIVE.getCurrentThreadId(); + client.on('spanStart', onSpanStart); + client.on('spanEnd', onSpanEnd); + } else if (lifecycle === 'trace') { + debug.warn('[Profiling] `lifecycle` is set to "trace", but tracing is disabled. The profiler will not run.'); + isAppStartPending = false; + } + + // The native SDK starts the app start profile before JS runs, so root spans that start before + // native init belong to it. Its id is only available after native init. + if (startOnAppStart && (lifecycle === 'manual' || tracingEnabled)) { + session = { + profilerId: new Promise(resolve => { + resolveAppStartSession = resolve; + }), + }; + } + + client.on('afterInit', () => { + isNativeReady = true; + const resolve = resolveAppStartSession; + resolveAppStartSession = undefined; + if (resolve) { + // The profiler already runs, so this call only returns its id. + NATIVE.startProfiler().then(resolve, () => resolve(null)); + } else if (startOnAppStart) { + stopNativeProfiler(); + return; + } + syncNativeProfiler(); + }); + + client.on('close', () => { + clearStopTimeout(); + resolveAppStartSession?.(null); + }); + }, + processEvent(event: Event): Event | PromiseLike { + const rootSpanId = event.type === 'transaction' ? event.contexts?.trace?.span_id : undefined; + const rootSpanSession = rootSpanId ? sessionByRootSpanId.get(rootSpanId) : undefined; + if (!rootSpanId || !rootSpanSession) { + return event; + } + + sessionByRootSpanId.delete(rootSpanId); + return rootSpanSession.profilerId.then(profilerId => { + if (profilerId) { + event.contexts = { + ...event.contexts, + // `ProfileContext` only types the `profile_id` of transaction-based profiles. + profile: { ...event.contexts?.profile, profiler_id: profilerId } as NonNullable< + Event['contexts'] + >['profile'], + }; + } + return event; + }); + }, + _profiler: { + start: () => setManualRequested(true), + stop: () => setManualRequested(false), + }, + }; +}; diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 8957e3d320..201398ca4c 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -130,6 +130,17 @@ interface SentryNativeWrapper { androidProfile?: NativeAndroidProfileEvent; } | null; + /** + * Starts the native UI profiler and resolves its profiler id, + * or `null` when the profiler does not run or the platform does not report the id. + */ + startProfiler(): Promise; + stopProfiler(): Promise; + /** + * Returns the native id of the calling thread, which is the JS thread, in the format of the native profiler. + */ + getCurrentThreadId(): string | null; + fetchNativePackageName(): string | null; /** @@ -825,6 +836,44 @@ export const NATIVE: SentryNativeWrapper = { return !!started; }, + async startProfiler(): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry) || typeof RNSentry.startProfiler !== 'function') { + return null; + } + + try { + return (await RNSentry.startProfiler()) ?? null; + } catch (error) { + debug.error('[NATIVE] Start Profiler Failed', error); + return null; + } + }, + + async stopProfiler(): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry) || typeof RNSentry.stopProfiler !== 'function') { + return; + } + + try { + await RNSentry.stopProfiler(); + } catch (error) { + debug.error('[NATIVE] Stop Profiler Failed', error); + } + }, + + getCurrentThreadId(): string | null { + if (!this.enableNative || !this._isModuleLoaded(RNSentry) || typeof RNSentry.getCurrentThreadId !== 'function') { + return null; + } + + try { + return RNSentry.getCurrentThreadId() ?? null; + } catch (error) { + debug.error('[NATIVE] Get Current Thread Id Failed', error); + return null; + } + }, + stopProfiling(): { hermesProfile: Hermes.Profile; nativeProfile?: NativeProfileEvent; diff --git a/packages/core/test/integrations/defaultUIProfiling.test.ts b/packages/core/test/integrations/defaultUIProfiling.test.ts new file mode 100644 index 0000000000..981f7e82de --- /dev/null +++ b/packages/core/test/integrations/defaultUIProfiling.test.ts @@ -0,0 +1,30 @@ +import type { ReactNativeClientOptions } from '../../src/js/options'; + +import { getDefaultIntegrations } from '../../src/js/integrations/default'; + +const getIntegrationNames = (overrides: Partial): string[] => + getDefaultIntegrations({ + dsn: 'https://example.com/1', + enableNative: true, + ...overrides, + } as ReactNativeClientOptions).map(integration => integration.name); + +describe('getDefaultIntegrations - UI profiling integration', () => { + it('adds the integration when profilingOptions are set', () => { + expect(getIntegrationNames({ _experiments: { profilingOptions: {} } })).toContain('ProfilingIntegration'); + }); + + it('adds the integration when the deprecated androidProfilingOptions are set', () => { + expect(getIntegrationNames({ _experiments: { androidProfilingOptions: {} } })).toContain('ProfilingIntegration'); + }); + + it('does not add the integration without profilingOptions', () => { + expect(getIntegrationNames({})).not.toContain('ProfilingIntegration'); + }); + + it('does not add the integration when native is disabled', () => { + expect(getIntegrationNames({ enableNative: false, _experiments: { profilingOptions: {} } })).not.toContain( + 'ProfilingIntegration', + ); + }); +}); diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 4a71d6b2cb..cd3e2ddee7 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -57,6 +57,9 @@ const NATIVE: MockInterface = { startProfiling: jest.fn(), stopProfiling: jest.fn(), + startProfiler: jest.fn(), + stopProfiler: jest.fn(), + getCurrentThreadId: jest.fn(), fetchNativePackageName: jest.fn(), fetchNativeStackFramesBy: jest.fn(), @@ -100,6 +103,9 @@ NATIVE.fetchModules.mockResolvedValue(null); NATIVE.fetchViewHierarchy.mockResolvedValue(null); NATIVE.startProfiling.mockReturnValue(false); NATIVE.stopProfiling.mockReturnValue(null); +NATIVE.startProfiler.mockResolvedValue(null); +NATIVE.stopProfiler.mockResolvedValue(undefined); +NATIVE.getCurrentThreadId.mockReturnValue(null); NATIVE.fetchNativePackageName.mockReturnValue('mock-native-package-name'); NATIVE.fetchNativeStackFramesBy.mockReturnValue(null); NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve()); diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts new file mode 100644 index 0000000000..505b2e0bbd --- /dev/null +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -0,0 +1,260 @@ +import type { Event } from '@sentry/core'; + +import { + debug, + getCurrentScope, + profiler, + setCurrentClient, + spanToJSON, + startInactiveSpan, + startSpan, +} from '@sentry/core'; + +import type { ProfilingOptions } from '../../src/js/options'; + +import { TRACE_STOP_DELAY_MS, uiProfilingIntegration } from '../../src/js/profiling/uiProfilingIntegration'; +import { getDefaultTestClientOptions, setupTestClient, TestClient } from '../mocks/client'; +import { NATIVE } from '../mockWrapper'; + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); + +function setupClient( + profilingOptions: ProfilingOptions, + clientOptions: { tracesSampleRate?: number } = {}, +): TestClient { + return setupTestClient({ ...clientOptions, integrations: [uiProfilingIntegration(profilingOptions)] }); +} + +function getTransaction(client: TestClient, name: string): Event | undefined { + return client.eventQueue.find(event => event.transaction === name); +} + +describe('uiProfilingIntegration', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['performance'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + describe('trace lifecycle', () => { + it('stops the native profiler one chunk after the last root span ends', () => { + setupClient({ lifecycle: 'trace' }); + + const span = startInactiveSpan({ name: 'root' }); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + + span.end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS - 1); + expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('keeps one profile for root spans that start before the stop delay ends', () => { + setupClient({ lifecycle: 'trace' }); + + startInactiveSpan({ name: 'first' }).end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS - 1); + const second = startInactiveSpan({ name: 'second' }); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS); + expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); + + second.end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('ignores child spans and unsampled root spans', () => { + setupClient({ lifecycle: 'trace' }, { tracesSampleRate: 0 }); + + startSpan({ name: 'unsampled' }, () => startInactiveSpan({ name: 'child' }).end()); + + expect(NATIVE.startProfiler).not.toHaveBeenCalled(); + }); + + it('adds the profiler id to a transaction that ends before the native start resolves', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('profiler-id'); + const client = setupClient({ lifecycle: 'trace' }); + + startInactiveSpan({ name: 'root' }).end(); + await jest.runAllTimersAsync(); + + expect(client.eventQueue).toHaveLength(1); + expect(client.event?.contexts?.profile).toEqual({ profiler_id: 'profiler-id' }); + }); + + it('does not add the id of a stopped profile to later transactions', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('profiler-id').mockResolvedValueOnce(null); + const client = setupClient({ lifecycle: 'trace' }); + + startInactiveSpan({ name: 'first' }).end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS); + startInactiveSpan({ name: 'second' }).end(); + await jest.runAllTimersAsync(); + + expect(client.eventQueue).toHaveLength(2); + expect(getTransaction(client, 'first')?.contexts?.profile).toEqual({ profiler_id: 'profiler-id' }); + expect(getTransaction(client, 'second')?.contexts?.profile).toBeUndefined(); + }); + + it('stops the app start profile after the first root span ends', () => { + setupClient({ lifecycle: 'trace', startOnAppStart: true }); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + + startInactiveSpan({ name: 'root' }).end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS); + + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('stops the app start profile when the first root span is not sampled', () => { + setupClient({ lifecycle: 'trace', startOnAppStart: true }, { tracesSampleRate: 0 }); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + + startInactiveSpan({ name: 'unsampled' }).end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS); + + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('links a root span that ends before native init to the app start profile', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('app-start-profiler-id'); + const client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + integrations: [uiProfilingIntegration({ lifecycle: 'trace', startOnAppStart: true })], + }), + ); + setCurrentClient(client); + client.init(); + + startInactiveSpan({ name: 'early' }).end(); + // @ts-expect-error Only available on ReactNativeClient + client.emit('afterInit'); + await jest.advanceTimersByTimeAsync(0); + + expect(getTransaction(client, 'early')?.contexts?.profile).toEqual({ profiler_id: 'app-start-profiler-id' }); + expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(TRACE_STOP_DELAY_MS); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('stops the app start profile after the profiler id call when manual stop comes before native init', async () => { + const client = new TestClient( + getDefaultTestClientOptions({ + integrations: [uiProfilingIntegration({ lifecycle: 'manual', startOnAppStart: true })], + }), + ); + setCurrentClient(client); + client.init(); + + profiler.stopProfiler(); + // @ts-expect-error Only available on ReactNativeClient + client.emit('afterInit'); + await jest.advanceTimersByTimeAsync(0); + + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.startProfiler.mock.invocationCallOrder[0]).toBeLessThan( + NATIVE.stopProfiler.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it('stops the app start profile when tracing is disabled', () => { + const warn = jest.spyOn(debug, 'warn'); + + setupClient({ lifecycle: 'trace', startOnAppStart: true }, { tracesSampleRate: undefined }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('tracing is disabled')); + expect(NATIVE.startProfiler).not.toHaveBeenCalled(); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + }); + + describe('thread id', () => { + it('adds the native JS thread id to spans', async () => { + NATIVE.getCurrentThreadId.mockReturnValueOnce('4242'); + const client = setupClient({ lifecycle: 'trace' }); + + startSpan({ name: 'root' }, () => startInactiveSpan({ name: 'child' }).end()); + await jest.runAllTimersAsync(); + + expect(client.event?.contexts?.trace?.data?.['thread.id']).toBe('4242'); + expect(client.event?.spans?.[0]?.data?.['thread.id']).toBe('4242'); + }); + + it('keeps a thread id that the span already has', () => { + NATIVE.getCurrentThreadId.mockReturnValueOnce('4242'); + setupClient({ lifecycle: 'trace' }); + + const span = startInactiveSpan({ name: 'root', attributes: { 'thread.id': '1' } }); + + expect(spanToJSON(span).data['thread.id']).toBe('1'); + }); + }); + + describe('manual lifecycle', () => { + it('starts and stops the native profiler through Sentry.profiler', () => { + setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + + profiler.stopProfiler(); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('waits for the native SDK before it starts the profiler', () => { + const client = new TestClient( + getDefaultTestClientOptions({ integrations: [uiProfilingIntegration({ lifecycle: 'manual' })] }), + ); + setCurrentClient(client); + client.init(); + + profiler.startProfiler(); + expect(NATIVE.startProfiler).not.toHaveBeenCalled(); + + // @ts-expect-error Only available on ReactNativeClient + client.emit('afterInit'); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + }); + + it('adds the profiler id only to transactions that start while the profiler runs', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('profiler-id'); + const client = setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + const profiled = startInactiveSpan({ name: 'profiled' }); + profiler.stopProfiler(); + profiled.end(); + startInactiveSpan({ name: 'not profiled' }).end(); + await jest.runAllTimersAsync(); + + expect(client.eventQueue).toHaveLength(2); + expect(getTransaction(client, 'profiled')?.contexts?.profile).toEqual({ profiler_id: 'profiler-id' }); + expect(getTransaction(client, 'not profiled')?.contexts?.profile).toBeUndefined(); + }); + + it('keeps the profiler id of the session that was running when the root span started', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('first-profiler-id').mockResolvedValueOnce('second-profiler-id'); + const client = setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + const span = startInactiveSpan({ name: 'root' }); + profiler.stopProfiler(); + profiler.startProfiler(); + span.end(); + await jest.runAllTimersAsync(); + + expect(getTransaction(client, 'root')?.contexts?.profile).toEqual({ profiler_id: 'first-profiler-id' }); + }); + }); +}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index b9e21fa5a9..c0c50b8be7 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -70,6 +70,9 @@ jest.mock('react-native', () => { resumeReplay: jest.fn(() => Promise.resolve()), flushReplay: jest.fn(() => Promise.resolve()), registerReplayTraceId: jest.fn(), + startProfiler: jest.fn(() => Promise.resolve('profiler-id')), + stopProfiler: jest.fn(() => Promise.resolve()), + getCurrentThreadId: jest.fn(() => '1234'), }; return { @@ -1744,6 +1747,67 @@ describe('Tests Native Wrapper', () => { }); }); + describe('startProfiler, stopProfiler and getCurrentThreadId', () => { + beforeEach(async () => { + await NATIVE.initNativeSdk({ + dsn: VALID_DSN, + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + }); + + it('resolves the native profiler id', async () => { + await expect(NATIVE.startProfiler()).resolves.toBe('profiler-id'); + }); + + it('calls the native stopProfiler', async () => { + await NATIVE.stopProfiler(); + + expect(RNSentry.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('resolves null when the methods are missing on the binary', async () => { + const module = RNSentry as unknown as Record; + const { startProfiler, stopProfiler } = module; + module.startProfiler = undefined; + module.stopProfiler = undefined; + + try { + await expect(NATIVE.startProfiler()).resolves.toBeNull(); + await expect(NATIVE.stopProfiler()).resolves.toBeUndefined(); + } finally { + module.startProfiler = startProfiler; + module.stopProfiler = stopProfiler; + } + }); + + it('returns the native thread id', () => { + expect(NATIVE.getCurrentThreadId()).toBe('1234'); + }); + + it('returns null when getCurrentThreadId is missing on the binary', () => { + const module = RNSentry as unknown as Record; + const { getCurrentThreadId } = module; + module.getCurrentThreadId = undefined; + + try { + expect(NATIVE.getCurrentThreadId()).toBeNull(); + } finally { + module.getCurrentThreadId = getCurrentThreadId; + } + }); + + it('swallows native rejections', async () => { + (RNSentry.startProfiler as jest.Mock).mockRejectedValueOnce(new Error('native boom')); + (RNSentry.stopProfiler as jest.Mock).mockRejectedValueOnce(new Error('native boom')); + + await expect(NATIVE.startProfiler()).resolves.toBeNull(); + await expect(NATIVE.stopProfiler()).resolves.toBeUndefined(); + }); + }); + describe('primitiveProcessor and _setPrimitiveProcessor', () => { describe('primitiveProcessor', () => { it('default primitiveProcessor returns value as string', () => {