From 86719938d90d23256c423eb8c913a0b5f33d507d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 20 Oct 2026 12:55:41 +0200 Subject: [PATCH 1/6] fix(profiling): Drive the native UI profiler from JS UI profiling never ran after app start. React Native turns off native tracing, so the native trace lifecycle never sees a sampled transaction. Also, no API let JS start or stop the native profiler, so the manual lifecycle could not work either. Add the startProfiler and stopProfiler bridge methods. On Android, they call Sentry.startProfiler() and Sentry.stopProfiler(), and startProfiler returns the profiler id. On iOS, they call SentrySDK. sentry-cocoa has no public API for the profiler id, so iOS returns nil. Always set the manual lifecycle on the native side, because both native SDKs refuse manual calls in trace mode. A new default integration, ProfilingIntegration, now chooses when the profiler runs. In trace mode, it runs while a sampled root span is active. In manual mode, it follows Sentry.profiler.startProfiler() and stopProfiler(), which are now exported. The integration waits for native init, stops an app start profile that is no longer needed, and adds the profiler id to transactions so that they link to the profile. --- CHANGELOG.md | 2 + .../react/RNSentryProfilingOptionsTest.kt | 77 +--------- .../RNSentryCocoaTesterTests/RNSentryTests.m | 4 +- .../io/sentry/react/RNSentryModuleImpl.java | 22 +++ .../java/io/sentry/react/RNSentryStart.java | 18 +-- .../java/io/sentry/react/RNSentryModule.java | 10 ++ .../java/io/sentry/react/RNSentryModule.java | 10 ++ packages/core/etc/sentry-react-native.api.md | 3 + packages/core/ios/RNSentry.mm | 25 ++++ .../core/ios/RNSentryExperimentalOptions.m | 13 +- packages/core/ios/SentrySDKWrapper.h | 4 + packages/core/ios/SentrySDKWrapper.m | 15 ++ packages/core/src/js/NativeRNSentry.ts | 2 + packages/core/src/js/index.ts | 1 + packages/core/src/js/integrations/default.ts | 5 + .../js/profiling/uiProfilingIntegration.ts | 127 +++++++++++++++++ packages/core/src/js/wrapper.ts | 32 +++++ .../integrations/defaultUIProfiling.test.ts | 30 ++++ packages/core/test/mockWrapper.ts | 4 + .../profiling/uiProfilingIntegration.test.ts | 132 ++++++++++++++++++ packages/core/test/wrapper.test.ts | 47 +++++++ 21 files changed, 479 insertions(+), 104 deletions(-) create mode 100644 packages/core/src/js/profiling/uiProfilingIntegration.ts create mode 100644 packages/core/test/integrations/defaultUIProfiling.test.ts create mode 100644 packages/core/test/profiling/uiProfilingIntegration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5dbb6ecae..b24efb6274 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` ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) + - 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..fecb286e9f 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 @@ -556,6 +556,28 @@ 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 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..011ee88a69 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,16 @@ public void flushReplay(Promise promise) { this.impl.flushReplay(promise); } + @Override + public void startProfiler(Promise promise) { + this.impl.startProfiler(promise); + } + + @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..0de2e43993 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,16 @@ public void flushReplay(Promise promise) { this.impl.flushReplay(promise); } + @ReactMethod + public void startProfiler(Promise promise) { + this.impl.startProfiler(promise); + } + + @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..0dd088c89e 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -1216,6 +1216,31 @@ + (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); + } +} + +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..2ac60e34b2 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -50,6 +50,8 @@ export interface Spec extends TurboModule { androidProfile?: UnsafeObject; error?: string; }; + startProfiler(): Promise; + stopProfiler(): Promise; 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..5f77a13d2e --- /dev/null +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -0,0 +1,127 @@ +import type { Event, Integration, Span } from '@sentry/core'; + +import { debug, getRootSpan, hasSpansEnabled, spanIsSampled } 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'; + +interface UIProfilingIntegration extends Integration { + _profiler: { + start: () => void; + stop: () => void; + }; +} + +/** + * 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(); + let isNativeReady = false; + let isRunning = false; + let isManualRequested = lifecycle === 'manual' && startOnAppStart; + let isAppStartPending = lifecycle === 'trace' && startOnAppStart; + let profilerId: string | undefined; + + const shouldRun = (): boolean => + lifecycle === 'trace' ? isAppStartPending || activeRootSpanIds.size > 0 : isManualRequested; + + const syncNativeProfiler = (): void => { + if (!isNativeReady || shouldRun() === isRunning) { + return; + } + + isRunning = !isRunning; + if (isRunning) { + NATIVE.startProfiler().then( + id => { + if (isRunning) { + profilerId = id ?? undefined; + } + }, + () => {}, + ); + } else { + // In trace mode, the transaction that ended the profile is processed after this call and must keep the id. + if (lifecycle === 'manual') { + profilerId = undefined; + } + NATIVE.stopProfiler().then(undefined, () => {}); + } + }; + + const onSpanStart = (span: Span): void => { + if (span !== getRootSpan(span) || !spanIsSampled(span)) { + return; + } + activeRootSpanIds.add(span.spanContext().spanId); + syncNativeProfiler(); + }; + + const onSpanEnd = (span: Span): void => { + if (!activeRootSpanIds.delete(span.spanContext().spanId)) { + 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 { + if (lifecycle === 'trace') { + if (hasSpansEnabled(client.getOptions())) { + client.on('spanStart', onSpanStart); + client.on('spanEnd', onSpanEnd); + } else { + debug.warn('[Profiling] `lifecycle` is set to "trace", but tracing is disabled. The profiler will not run.'); + isAppStartPending = false; + } + } + + client.on('afterInit', () => { + isNativeReady = true; + if (startOnAppStart && !shouldRun()) { + // The native SDK started the app start profile before JS ran. + NATIVE.stopProfiler().then(undefined, () => {}); + return; + } + syncNativeProfiler(); + }); + }, + processEvent(event: Event): Event { + if (event.type === 'transaction' && profilerId) { + event.contexts = { + ...event.contexts, + // `ProfileContext` only types the `profile_id` of transaction-based profiles. + profile: { ...event.contexts?.profile, profiler_id: profilerId } as NonNullable['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..5f964f4ded 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -130,6 +130,13 @@ 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; + fetchNativePackageName(): string | null; /** @@ -825,6 +832,31 @@ 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); + } + }, + 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..4d871fe383 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -57,6 +57,8 @@ const NATIVE: MockInterface = { startProfiling: jest.fn(), stopProfiling: jest.fn(), + startProfiler: jest.fn(), + stopProfiler: jest.fn(), fetchNativePackageName: jest.fn(), fetchNativeStackFramesBy: jest.fn(), @@ -100,6 +102,8 @@ 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.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..91f23dbb30 --- /dev/null +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -0,0 +1,132 @@ +import { debug, getCurrentScope, profiler, setCurrentClient, startInactiveSpan, startSpan } from '@sentry/core'; + +import type { ProfilingOptions } from '../../src/js/options'; + +import { 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')); + +const flushPromises = (): Promise => new Promise(resolve => setImmediate(resolve)); + +function setupClient( + profilingOptions: ProfilingOptions, + clientOptions: { tracesSampleRate?: number } = {}, +): TestClient { + return setupTestClient({ ...clientOptions, integrations: [uiProfilingIntegration(profilingOptions)] }); +} + +describe('uiProfilingIntegration', () => { + afterEach(() => { + jest.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + describe('trace lifecycle', () => { + it('runs the native profiler while a sampled root span is active', () => { + setupClient({ lifecycle: 'trace' }); + + const span = startInactiveSpan({ name: 'root' }); + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); + + span.end(); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + it('keeps the profiler running until the last root span ends', () => { + setupClient({ lifecycle: 'trace' }); + + const first = startInactiveSpan({ name: 'first', forceTransaction: true }); + const second = startInactiveSpan({ name: 'second', forceTransaction: true }); + first.end(); + expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); + + second.end(); + 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 native profiler id to transactions', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('profiler-id'); + const client = setupClient({ lifecycle: 'trace' }); + + const span = startInactiveSpan({ name: 'root' }); + await flushPromises(); + span.end(); + await client.flush(); + + expect(client.event?.contexts?.profile).toEqual({ profiler_id: 'profiler-id' }); + }); + + 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(); + + expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); + expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); + }); + + 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('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('does not add the profiler id to transactions after the profiler stops', async () => { + NATIVE.startProfiler.mockResolvedValueOnce('profiler-id'); + const client = setupClient({ lifecycle: 'manual' }); + + profiler.startProfiler(); + await flushPromises(); + profiler.stopProfiler(); + startInactiveSpan({ name: 'root' }).end(); + await client.flush(); + + expect(client.event?.type).toBe('transaction'); + expect(client.event?.contexts?.profile).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index b9e21fa5a9..2253d36732 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -70,6 +70,8 @@ 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()), }; return { @@ -1744,6 +1746,51 @@ describe('Tests Native Wrapper', () => { }); }); + describe('startProfiler and stopProfiler', () => { + 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('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', () => { From 266b1cb73bca14366ca24242c6581632bae929ec Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 20 Oct 2026 12:56:06 +0200 Subject: [PATCH 2/6] docs(changelog): Add the PR link for the UI profiler fix Replace the PR number placeholder in the CHANGELOG entry with #6916. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24efb6274..9f5020e792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### 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` ([#XXXX](https://github.com/getsentry/sentry-react-native/pull/XXXX)) +- 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 From 87bb282d40dff2e0ca81ba7b6ed02fabd88ba51e Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 20 Oct 2026 13:38:02 +0200 Subject: [PATCH 3/6] fix(profiling): Link profiles per session and delay the trace stop Warden found two problems with the profiler id. The integration kept one id for all transactions, so transactions that ran after the profile stopped still got the id of the stopped profile. Also, the id arrived asynchronously and was dropped when the root span ended before the native start call resolved. Each start call now creates a session that holds the promise of the native start call. Each sampled root span that is active while a session runs is mapped to that session, and processEvent waits for the promise before it adds the id. A transaction now gets only the id of a profile that ran during its root span. In trace mode, the integration now waits one 60 second chunk after the last root span ends before it calls stop. The native SDKs ignore a start call that comes before a requested stop takes effect at the end of the chunk, so back-to-back root spans lost their profile after the chunk ended. --- .../js/profiling/uiProfilingIntegration.ts | 107 ++++++++++++------ .../profiling/uiProfilingIntegration.test.ts | 70 ++++++++---- 2 files changed, 121 insertions(+), 56 deletions(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index 5f77a13d2e..d7546a8922 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -10,6 +10,13 @@ import { NATIVE } from '../wrapper'; // `Sentry.profiler` from `@sentry/core` looks the integration up by this name. const INTEGRATION_NAME = 'ProfilingIntegration'; +/** + * 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; @@ -17,6 +24,10 @@ interface UIProfilingIntegration extends Integration { }; } +interface ProfileSession { + profilerId: Promise; +} + /** * Starts and stops the native UI profiler from JS. * @@ -28,36 +39,45 @@ export const uiProfilingIntegration = ({ startOnAppStart = false, }: ProfilingOptions): UIProfilingIntegration => { const activeRootSpanIds = new Set(); + const sessionByRootSpanId = new Map(); + let session: ProfileSession | undefined; + let stopTimeout: ReturnType | undefined; let isNativeReady = false; - let isRunning = false; let isManualRequested = lifecycle === 'manual' && startOnAppStart; let isAppStartPending = lifecycle === 'trace' && startOnAppStart; - let profilerId: string | 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 || shouldRun() === isRunning) { + if (!isNativeReady) { return; } - isRunning = !isRunning; - if (isRunning) { - NATIVE.startProfiler().then( - id => { - if (isRunning) { - profilerId = id ?? undefined; - } - }, - () => {}, - ); - } else { - // In trace mode, the transaction that ended the profile is processed after this call and must keep the id. - if (lifecycle === 'manual') { - profilerId = undefined; + if (shouldRun()) { + clearStopTimeout(); + if (!session) { + const newSession: ProfileSession = { profilerId: NATIVE.startProfiler() }; + session = newSession; + activeRootSpanIds.forEach(spanId => sessionByRootSpanId.set(spanId, newSession)); + } + } else if (session && !stopTimeout) { + if (lifecycle === 'trace') { + stopTimeout = setTimeout(stopNativeProfiler, TRACE_STOP_DELAY_MS); + } else { + stopNativeProfiler(); } - NATIVE.stopProfiler().then(undefined, () => {}); } }; @@ -65,7 +85,11 @@ export const uiProfilingIntegration = ({ if (span !== getRootSpan(span) || !spanIsSampled(span)) { return; } - activeRootSpanIds.add(span.spanContext().spanId); + const { spanId } = span.spanContext(); + activeRootSpanIds.add(spanId); + if (session) { + sessionByRootSpanId.set(spanId, session); + } syncNativeProfiler(); }; @@ -89,35 +113,46 @@ export const uiProfilingIntegration = ({ return { name: INTEGRATION_NAME, setup(client: ReactNativeClient): void { - if (lifecycle === 'trace') { - if (hasSpansEnabled(client.getOptions())) { - client.on('spanStart', onSpanStart); - client.on('spanEnd', onSpanEnd); - } else { - debug.warn('[Profiling] `lifecycle` is set to "trace", but tracing is disabled. The profiler will not run.'); - isAppStartPending = false; - } + if (hasSpansEnabled(client.getOptions())) { + 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; } client.on('afterInit', () => { isNativeReady = true; if (startOnAppStart && !shouldRun()) { // The native SDK started the app start profile before JS ran. - NATIVE.stopProfiler().then(undefined, () => {}); + stopNativeProfiler(); return; } syncNativeProfiler(); }); + + client.on('close', clearStopTimeout); }, - processEvent(event: Event): Event { - if (event.type === 'transaction' && profilerId) { - event.contexts = { - ...event.contexts, - // `ProfileContext` only types the `profile_id` of transaction-based profiles. - profile: { ...event.contexts?.profile, profiler_id: profilerId } as NonNullable['profile'], - }; + 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; } - 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), diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index 91f23dbb30..2d3bc99743 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -1,15 +1,15 @@ +import type { Event } from '@sentry/core'; + import { debug, getCurrentScope, profiler, setCurrentClient, startInactiveSpan, startSpan } from '@sentry/core'; import type { ProfilingOptions } from '../../src/js/options'; -import { uiProfilingIntegration } from '../../src/js/profiling/uiProfilingIntegration'; +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')); -const flushPromises = (): Promise => new Promise(resolve => setImmediate(resolve)); - function setupClient( profilingOptions: ProfilingOptions, clientOptions: { tracesSampleRate?: number } = {}, @@ -17,33 +17,47 @@ function setupClient( 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('runs the native profiler while a sampled root span is active', () => { + 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); - expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); span.end(); + jest.advanceTimersByTime(TRACE_STOP_DELAY_MS - 1); + expect(NATIVE.stopProfiler).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); expect(NATIVE.stopProfiler).toHaveBeenCalledTimes(1); }); - it('keeps the profiler running until the last root span ends', () => { + it('keeps one profile for root spans that start before the stop delay ends', () => { setupClient({ lifecycle: 'trace' }); - const first = startInactiveSpan({ name: 'first', forceTransaction: true }); - const second = startInactiveSpan({ name: 'second', forceTransaction: true }); - first.end(); + 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); }); @@ -56,23 +70,37 @@ describe('uiProfilingIntegration', () => { expect(NATIVE.startProfiler).not.toHaveBeenCalled(); }); - it('adds the native profiler id to transactions', async () => { + 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' }); - const span = startInactiveSpan({ name: 'root' }); - await flushPromises(); - span.end(); - await client.flush(); + 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); @@ -115,18 +143,20 @@ describe('uiProfilingIntegration', () => { expect(NATIVE.startProfiler).toHaveBeenCalledTimes(1); }); - it('does not add the profiler id to transactions after the profiler stops', async () => { + 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(); - await flushPromises(); + const profiled = startInactiveSpan({ name: 'profiled' }); profiler.stopProfiler(); - startInactiveSpan({ name: 'root' }).end(); - await client.flush(); + profiled.end(); + startInactiveSpan({ name: 'not profiled' }).end(); + await jest.runAllTimersAsync(); - expect(client.event?.type).toBe('transaction'); - expect(client.event?.contexts?.profile).toBeUndefined(); + expect(client.eventQueue).toHaveLength(2); + expect(getTransaction(client, 'profiled')?.contexts?.profile).toEqual({ profiler_id: 'profiler-id' }); + expect(getTransaction(client, 'not profiled')?.contexts?.profile).toBeUndefined(); }); }); }); From 2bacd0abcdc4bef628912d5b5c429d34f512cb5e Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 20 Oct 2026 14:45:52 +0200 Subject: [PATCH 4/6] feat(profiling): Add the native JS thread id to spans Sentry opens the profile of a span on the thread from the span's thread.id. React Native spans had no thread.id, so the profile opened on the main thread, and the JS work was in another, unnamed thread. Add the getCurrentThreadId bridge method. It runs synchronously on the JS thread and returns the thread id in the format of the native profiler: the Linux thread id on Android and the Mach thread port on iOS. The UI profiling integration reads it once and sets thread.id on every span that does not have one. --- .../io/sentry/react/RNSentryModuleImpl.java | 5 +++ .../java/io/sentry/react/RNSentryModule.java | 5 +++ .../java/io/sentry/react/RNSentryModule.java | 5 +++ packages/core/ios/RNSentry.mm | 7 ++++ packages/core/src/js/NativeRNSentry.ts | 1 + .../js/profiling/uiProfilingIntegration.ts | 10 +++++- packages/core/src/js/wrapper.ts | 17 ++++++++++ packages/core/test/mockWrapper.ts | 2 ++ .../profiling/uiProfilingIntegration.test.ts | 32 ++++++++++++++++++- packages/core/test/wrapper.test.ts | 19 ++++++++++- 10 files changed, 100 insertions(+), 3 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 fecb286e9f..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; @@ -568,6 +569,10 @@ public void startProfiler(Promise promise) { } } + public String getCurrentThreadId() { + return String.valueOf(Process.myTid()); + } + public void stopProfiler(Promise promise) { try { Sentry.stopProfiler(); 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 011ee88a69..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 @@ -239,6 +239,11 @@ 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); 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 0de2e43993..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 @@ -239,6 +239,11 @@ 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); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 0dd088c89e..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 @@ -1229,6 +1230,12 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path } } +// 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) { diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 2ac60e34b2..2a5be08dc5 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -52,6 +52,7 @@ export interface Spec extends TurboModule { }; 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/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index d7546a8922..52774e476c 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -1,6 +1,6 @@ import type { Event, Integration, Span } from '@sentry/core'; -import { debug, getRootSpan, hasSpansEnabled, spanIsSampled } from '@sentry/core'; +import { debug, getRootSpan, hasSpansEnabled, spanIsSampled, spanToJSON } from '@sentry/core'; import type { ReactNativeClient } from '../client'; import type { ProfilingOptions } from '../options'; @@ -10,6 +10,8 @@ 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 @@ -45,6 +47,7 @@ export const uiProfilingIntegration = ({ let isNativeReady = false; let isManualRequested = lifecycle === 'manual' && startOnAppStart; let isAppStartPending = lifecycle === 'trace' && startOnAppStart; + let jsThreadId: string | null = null; const shouldRun = (): boolean => lifecycle === 'trace' ? isAppStartPending || activeRootSpanIds.size > 0 : isManualRequested; @@ -82,6 +85,10 @@ export const uiProfilingIntegration = ({ }; 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; } @@ -114,6 +121,7 @@ export const uiProfilingIntegration = ({ name: INTEGRATION_NAME, setup(client: ReactNativeClient): void { if (hasSpansEnabled(client.getOptions())) { + jsThreadId = NATIVE.getCurrentThreadId(); client.on('spanStart', onSpanStart); client.on('spanEnd', onSpanEnd); } else if (lifecycle === 'trace') { diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 5f964f4ded..201398ca4c 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -136,6 +136,10 @@ interface SentryNativeWrapper { */ 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; @@ -857,6 +861,19 @@ export const NATIVE: SentryNativeWrapper = { } }, + 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/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 4d871fe383..cd3e2ddee7 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -59,6 +59,7 @@ const NATIVE: MockInterface = { stopProfiling: jest.fn(), startProfiler: jest.fn(), stopProfiler: jest.fn(), + getCurrentThreadId: jest.fn(), fetchNativePackageName: jest.fn(), fetchNativeStackFramesBy: jest.fn(), @@ -104,6 +105,7 @@ 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 index 2d3bc99743..411f368c67 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -1,6 +1,14 @@ import type { Event } from '@sentry/core'; -import { debug, getCurrentScope, profiler, setCurrentClient, startInactiveSpan, startSpan } from '@sentry/core'; +import { + debug, + getCurrentScope, + profiler, + setCurrentClient, + spanToJSON, + startInactiveSpan, + startSpan, +} from '@sentry/core'; import type { ProfilingOptions } from '../../src/js/options'; @@ -117,6 +125,28 @@ describe('uiProfilingIntegration', () => { }); }); + 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' }); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index 2253d36732..c0c50b8be7 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -72,6 +72,7 @@ jest.mock('react-native', () => { registerReplayTraceId: jest.fn(), startProfiler: jest.fn(() => Promise.resolve('profiler-id')), stopProfiler: jest.fn(() => Promise.resolve()), + getCurrentThreadId: jest.fn(() => '1234'), }; return { @@ -1746,7 +1747,7 @@ describe('Tests Native Wrapper', () => { }); }); - describe('startProfiler and stopProfiler', () => { + describe('startProfiler, stopProfiler and getCurrentThreadId', () => { beforeEach(async () => { await NATIVE.initNativeSdk({ dsn: VALID_DSN, @@ -1782,6 +1783,22 @@ describe('Tests Native Wrapper', () => { } }); + 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')); From 4931e63450a04fe6d0a6057d25e96998db97bc0a Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 20 Oct 2026 15:56:21 +0200 Subject: [PATCH 5/6] fix(profiling): Keep the first session of a root span and stop on unsampled roots Warden found two more problems in the UI profiling integration. In manual mode, a new session overwrote the session of root spans that started under an earlier session. A transaction could then get the id of a later profile. A root span now keeps the session that was running when it started. In trace mode with startOnAppStart, unsampled root spans were not tracked, so their end never cleared the pending app start profile. With tracesSampleRate: 0, the profiler then ran for the whole session. The end of any root span now ends the app start profile. --- .../js/profiling/uiProfilingIntegration.ts | 10 ++++++-- .../profiling/uiProfilingIntegration.test.ts | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index 52774e476c..0be64d8e83 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -73,7 +73,12 @@ export const uiProfilingIntegration = ({ if (!session) { const newSession: ProfileSession = { profilerId: NATIVE.startProfiler() }; session = newSession; - activeRootSpanIds.forEach(spanId => sessionByRootSpanId.set(spanId, 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') { @@ -101,7 +106,8 @@ export const uiProfilingIntegration = ({ }; const onSpanEnd = (span: Span): void => { - if (!activeRootSpanIds.delete(span.spanContext().spanId)) { + // 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; diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index 411f368c67..ccf0c4709a 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -114,6 +114,16 @@ describe('uiProfilingIntegration', () => { 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('stops the app start profile when tracing is disabled', () => { const warn = jest.spyOn(debug, 'warn'); @@ -188,5 +198,19 @@ describe('uiProfilingIntegration', () => { 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' }); + }); }); }); From be8626e91ccc9158a5cf4af63c13dfbe2f135afa Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 21 Oct 2026 10:34:44 +0200 Subject: [PATCH 6/6] fix(profiling): Link root spans that start before native init to the app start profile With startOnAppStart, the native SDK starts the profile before JS runs. Root spans that started and ended before native init had no session, so their transactions got no profiler id. Also, the afterInit handler then saw nothing to run and stopped the app start profile at once, without the delay that trace mode uses. The integration now creates the app start session in setup. Root spans that start before native init map to it, and its id resolves when native init completes. After that, the normal logic decides when the profiler stops. The session resolves without an id if the client closes before native init, so no transaction waits forever. --- .../js/profiling/uiProfilingIntegration.ts | 27 ++++++++++-- .../profiling/uiProfilingIntegration.test.ts | 44 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/profiling/uiProfilingIntegration.ts b/packages/core/src/js/profiling/uiProfilingIntegration.ts index 0be64d8e83..9dea2b5683 100644 --- a/packages/core/src/js/profiling/uiProfilingIntegration.ts +++ b/packages/core/src/js/profiling/uiProfilingIntegration.ts @@ -48,6 +48,7 @@ export const uiProfilingIntegration = ({ 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; @@ -126,7 +127,8 @@ export const uiProfilingIntegration = ({ return { name: INTEGRATION_NAME, setup(client: ReactNativeClient): void { - if (hasSpansEnabled(client.getOptions())) { + const tracingEnabled = hasSpansEnabled(client.getOptions()); + if (tracingEnabled) { jsThreadId = NATIVE.getCurrentThreadId(); client.on('spanStart', onSpanStart); client.on('spanEnd', onSpanEnd); @@ -135,17 +137,34 @@ export const uiProfilingIntegration = ({ 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; - if (startOnAppStart && !shouldRun()) { - // The native SDK started the app start profile before JS ran. + 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); + client.on('close', () => { + clearStopTimeout(); + resolveAppStartSession?.(null); + }); }, processEvent(event: Event): Event | PromiseLike { const rootSpanId = event.type === 'transaction' ? event.contexts?.trace?.span_id : undefined; diff --git a/packages/core/test/profiling/uiProfilingIntegration.test.ts b/packages/core/test/profiling/uiProfilingIntegration.test.ts index ccf0c4709a..505b2e0bbd 100644 --- a/packages/core/test/profiling/uiProfilingIntegration.test.ts +++ b/packages/core/test/profiling/uiProfilingIntegration.test.ts @@ -124,6 +124,50 @@ describe('uiProfilingIntegration', () => { 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');