Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
- Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915))
- Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921))
- To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option.
- 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.
- The SDK adds the new `uiProfilingIntegration` by default when `profilingOptions` is set. If you set `defaultIntegrations: false`, add `Sentry.uiProfilingIntegration()` to `integrations`, because the SDK ignores `profilingOptions` without it.

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,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);
}

Expand Down Expand Up @@ -673,7 +673,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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -826,6 +827,8 @@ export const primitiveTagIntegration: () => Integration;

export { Profiler }

export { profiler }

// @public
export type ProfilingLifecycle = 'trace' | 'manual';

Expand Down Expand Up @@ -1276,6 +1279,9 @@ export interface TurboModuleContextOptions {
slowCallThresholdMs?: number;
}

// @public
export const uiProfilingIntegration: () => Integration;

// @public (undocumented)
export const Unmask: HostComponent<ViewProps> | React_2.ComponentType<ViewProps>;

Expand Down
32 changes: 32 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import "RNSentry.h"
#import "RNSentryTimeToDisplay.h"
#import <dlfcn.h>
#import <pthread.h>

#if __has_include(<React/RCTConvert.h>)
# import <React/RCTConvert.h>
Expand Down Expand Up @@ -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)
{
Expand Down
13 changes: 2 additions & 11 deletions packages/core/ios/RNSentryExperimentalOptions.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
4 changes: 4 additions & 0 deletions packages/core/ios/SentrySDKWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

+ (void)resumeAppHangTracking;

+ (void)startProfiler;

+ (void)stopProfiler;

+ (BOOL)debug;

+ (NSString *)releaseName;
Expand Down
15 changes: 15 additions & 0 deletions packages/core/ios/SentrySDKWrapper.m
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#import "SentrySDKWrapper.h"
#import "RNSentrySwiftBridge.h"
#import <Sentry/SentryProfilingConditionals.h>
@import Sentry;

@implementation SentrySDKWrapper
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export interface Spec extends TurboModule {
androidProfile?: UnsafeObject;
error?: string;
};
startProfiler(): Promise<string | undefined | null>;
stopProfiler(): Promise<void>;
getCurrentThreadId(): string | undefined | null;
fetchNativePackageName(): string | undefined | null;
fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null;
initNativeReactNavigationNewFrameTracking(): Promise<void>;
Expand Down
Loading
Loading