Repository: getsentry/sentry-react-native (TypeScript)

Issue:
Enable Spotlight in Android and iOS SDKs 
### Description

At the moment Spotlight is only enabled in the JS layer of React Native. Since Android and iOS also support sending events to Spotlight we can enable the native integrations from React Native.

When Spotlight is enabled in RN, it should be automatically enabled also on Android and iOS.



Fix (patch):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3118925cc1..ef2aade826 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@
 
 - Enhanced accuracy of time-to-display spans. ([#4189](https://github.com/getsentry/sentry-react-native/pull/4189))
 
+### Features
+
+- Enables Spotlight in Android and iOS SDKs ([#4211](https://github.com/getsentry/sentry-react-native/pull/4211))
+
 ### Dependencies
 
 - Bump JavaScript SDK from v8.34.0 to v8.35.0 ([#4196](https://github.com/getsentry/sentry-react-native/pull/4196))
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm
index 615deb45c4..1ef91af466 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm
@@ -122,6 +122,86 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled
     XCTAssertEqual(actualOptions.enableAutoPerformanceTracing, false, @"Did not disable Auto Performance Tracing");
 }
 
+- (void)testCreateOptionsWithDictionarySpotlightEnabled
+{
+    RNSentry * rnSentry = [[RNSentry alloc] init];
+    NSError* error = nil;
+
+    NSDictionary *_Nonnull mockedReactNativeDictionary = @{
+        @"dsn": @"https://abcd@efgh.ingest.sentry.io/123456",
+        @"spotlight": @YES,
+        @"defaultSidecarUrl": @"http://localhost:8969/teststream",
+    };
+    SentryOptions* actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary error:&error];
+    XCTAssertNotNil(actualOptions, @"Did not create sentry options");
+    XCTAssertNil(error, @"Should not pass no error");
+    XCTAssertTrue(actualOptions.enableSpotlight , @"Did not enable spotlight");
+    XCTAssertEqual(actualOptions.spotlightUrl , @"http://localhost:8969/teststream");
+}
+
+- (void)testCreateOptionsWithDictionarySpotlightOne
+{
+    RNSentry * rnSentry = [[RNSentry alloc] init];
+    NSError* error = nil;
+
+    NSDictionary *_Nonnull mockedReactNativeDictionary = @{
+        @"dsn": @"https://abcd@efgh.ingest.sentry.io/123456",
+        @"spotlight": @1,
+        @"defaultSidecarUrl": @"http://localhost:8969/teststream",
+    };
+    SentryOptions* actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary error:&error];
+    XCTAssertNotNil(actualOptions, @"Did not create sentry options");
+    XCTAssertNil(error, @"Should not pass no error");
+    XCTAssertTrue(actualOptions.enableSpotlight , @"Did not enable spotlight");
+    XCTAssertEqual(actualOptions.spotlightUrl , @"http://localhost:8969/teststream");
+}
+
+- (void)testCreateOptionsWithDictionarySpotlightUrl
+{
+    RNSentry * rnSentry = [[RNSentry alloc] init];
+    NSError* error = nil;
+
+    NSDictionary *_Nonnull mockedReactNativeDictionary = @{
+        @"dsn": @"https://abcd@efgh.ingest.sentry.io/123456",
+        @"spotlight": @"http://localhost:8969/teststream",
+    };
+    SentryOptions* actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary error:&error];
+    XCTAssertNotNil(actualOptions, @"Did not create sentry options");
+    XCTAssertNil(error, @"Should not pass no error");
+    XCTAssertTrue(actualOptions.enableSpotlight , @"Did not enable spotlight");
+    XCTAssertEqual(actualOptions.spotlightUrl , @"http://localhost:8969/teststream");
+}
+
+- (void)testCreateOptionsWithDictionarySpotlightDisabled
+{
+    RNSentry * rnSentry = [[RNSentry alloc] init];
+    NSError* error = nil;
+
+    NSDictionary *_Nonnull mockedReactNativeDictionary = @{
+        @"dsn": @"https://abcd@efgh.ingest.sentry.io/123456",
+        @"spotlight": @NO,
+    };
+    SentryOptions* actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary error:&error];
+    XCTAssertNotNil(actualOptions, @"Did not create sentry options");
+    XCTAssertNil(error, @"Should not pass no error");
+    XCTAssertFalse(actualOptions.enableSpotlight, @"Did not disable spotlight");
+}
+
+- (void)testCreateOptionsWithDictionarySpotlightZero
+{
+    RNSentry * rnSentry = [[RNSentry alloc] init];
+    NSError* error = nil;
+
+    NSDictionary *_Nonnull mockedReactNativeDictionary = @{
+        @"dsn": @"https://abcd@efgh.ingest.sentry.io/123456",
+        @"spotlight": @0,
+    };
+    SentryOptions* actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary error:&error];
+    XCTAssertNotNil(actualOptions, @"Did not create sentry options");
+    XCTAssertNil(error, @"Should not pass no error");
+    XCTAssertFalse(actualOptions.enableSpotlight, @"Did not disable spotlight");
+}
+
 - (void)testPassesErrorOnWrongDsn
 {
     RNSentry * rnSentry = [[RNSentry alloc] init];
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 7472187dd4..e291b78b2c 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
@@ -20,6 +20,7 @@
 import com.facebook.react.bridge.ReactApplicationContext;
 import com.facebook.react.bridge.ReadableMap;
 import com.facebook.react.bridge.ReadableMapKeySetIterator;
+import com.facebook.react.bridge.ReadableType;
 import com.facebook.react.bridge.UiThreadUtil;
 import com.facebook.react.bridge.WritableArray;
 import com.facebook.react.bridge.WritableMap;
@@ -175,135 +176,143 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) {
   public void initNativeSdk(final ReadableMap rnOptions, Promise promise) {
     SentryAndroid.init(
         this.getReactApplicationContext(),
-        options -> {
-          @Nullable SdkVersion sdkVersion = options.getSdkVersion();
-          if (sdkVersion == null) {
-            sdkVersion = new SdkVersion(ANDROID_SDK_NAME, BuildConfig.VERSION_NAME);
-          } else {
-            sdkVersion.setName(ANDROID_SDK_NAME);
-          }
-
-          options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion());
-          options.setNativeSdkName(NATIVE_SDK_NAME);
-          options.setSdkVersion(sdkVersion);
+        options -> getSentryAndroidOptions(options, rnOptions, logger));
 
-          if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) {
-            options.setDebug(true);
-          }
-          if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) {
-            String dsn = rnOptions.getString("dsn");
-            logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn));
-            options.setDsn(dsn);
-          } else {
-            // SentryAndroid needs an empty string fallback for the dsn.
-            options.setDsn("");
-          }
-          if (rnOptions.hasKey("sampleRate")) {
-            options.setSampleRate(rnOptions.getDouble("sampleRate"));
-          }
-          if (rnOptions.hasKey("sendClientReports")) {
-            options.setSendClientReports(rnOptions.getBoolean("sendClientReports"));
-          }
-          if (rnOptions.hasKey("maxBreadcrumbs")) {
-            options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs"));
-          }
-          if (rnOptions.hasKey("maxCacheItems")) {
-            options.setMaxCacheItems(rnOptions.getInt("maxCacheItems"));
-          }
-          if (rnOptions.hasKey("environment") && rnOptions.getString("environment") != null) {
-            options.setEnvironment(rnOptions.getString("environment"));
-          }
-          if (rnOptions.hasKey("release") && rnOptions.getString("release") != null) {
-            options.setRelease(rnOptions.getString("release"));
-          }
-          if (rnOptions.hasKey("dist") && rnOptions.getString("dist") != null) {
-            options.setDist(rnOptions.getString("dist"));
-          }
-          if (rnOptions.hasKey("enableAutoSessionTracking")) {
-            options.setEnableAutoSessionTracking(rnOptions.getBoolean("enableAutoSessionTracking"));
-          }
-          if (rnOptions.hasKey("sessionTrackingIntervalMillis")) {
-            options.setSessionTrackingIntervalMillis(
-                rnOptions.getInt("sessionTrackingIntervalMillis"));
-          }
-          if (rnOptions.hasKey("shutdownTimeout")) {
-            options.setShutdownTimeoutMillis(rnOptions.getInt("shutdownTimeout"));
-          }
-          if (rnOptions.hasKey("enableNdkScopeSync")) {
-            options.setEnableScopeSync(rnOptions.getBoolean("enableNdkScopeSync"));
-          }
-          if (rnOptions.hasKey("attachStacktrace")) {
-            options.setAttachStacktrace(rnOptions.getBoolean("attachStacktrace"));
-          }
-          if (rnOptions.hasKey("attachThreads")) {
-            // JS use top level stacktrace and android attaches Threads which hides them so
-            // by default we hide.
-            options.setAttachThreads(rnOptions.getBoolean("attachThreads"));
-          }
-          if (rnOptions.hasKey("attachScreenshot")) {
-            options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot"));
-          }
-          if (rnOptions.hasKey("attachViewHierarchy")) {
-            options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy"));
-          }
-          if (rnOptions.hasKey("sendDefaultPii")) {
-            options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii"));
-          }
-          if (rnOptions.hasKey("maxQueueSize")) {
-            options.setMaxQueueSize(rnOptions.getInt("maxQueueSize"));
-          }
-          if (rnOptions.hasKey("enableNdk")) {
-            options.setEnableNdk(rnOptions.getBoolean("enableNdk"));
-          }
-          if (rnOptions.hasKey("_experiments")) {
-            options.getExperimental().setSessionReplay(getReplayOptions(rnOptions));
-            options
-                .getReplayController()
-                .setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter());
-          }
-          options.setBeforeSend(
-              (event, hint) -> {
-                // React native internally throws a JavascriptException
-                // Since we catch it before that, we don't want to send this one
-                // because we would send it twice
-                try {
-                  SentryException ex = event.getExceptions().get(0);
-                  if (null != ex && ex.getType().contains("JavascriptException")) {
-                    return null;
-                  }
-                } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
-                  // We do nothing
-                }
+    promise.resolve(true);
+  }
 
-                setEventOriginTag(event);
-                addPackages(event, options.getSdkVersion());
+  protected void getSentryAndroidOptions(
+      @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) {
+    @Nullable SdkVersion sdkVersion = options.getSdkVersion();
+    if (sdkVersion == null) {
+      sdkVersion = new SdkVersion(ANDROID_SDK_NAME, BuildConfig.VERSION_NAME);
+    } else {
+      sdkVersion.setName(ANDROID_SDK_NAME);
+    }
 
-                return event;
-              });
+    options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion());
+    options.setNativeSdkName(NATIVE_SDK_NAME);
+    options.setSdkVersion(sdkVersion);
 
-          if (rnOptions.hasKey("enableNativeCrashHandling")
-              && !rnOptions.getBoolean("enableNativeCrashHandling")) {
-            final List<Integration> integrations = options.getIntegrations();
-            for (final Integration integration : integrations) {
-              if (integration instanceof UncaughtExceptionHandlerIntegration
-                  || integration instanceof AnrIntegration
-                  || integration instanceof NdkIntegration) {
-                integrations.remove(integration);
-              }
+    if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) {
+      options.setDebug(true);
+    }
+    if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) {
+      String dsn = rnOptions.getString("dsn");
+      logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn));
+      options.setDsn(dsn);
+    } else {
+      // SentryAndroid needs an empty string fallback for the dsn.
+      options.setDsn("");
+    }
+    if (rnOptions.hasKey("sampleRate")) {
+      options.setSampleRate(rnOptions.getDouble("sampleRate"));
+    }
+    if (rnOptions.hasKey("sendClientReports")) {
+      options.setSendClientReports(rnOptions.getBoolean("sendClientReports"));
+    }
+    if (rnOptions.hasKey("maxBreadcrumbs")) {
+      options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs"));
+    }
+    if (rnOptions.hasKey("maxCacheItems")) {
+      options.setMaxCacheItems(rnOptions.getInt("maxCacheItems"));
+    }
+    if (rnOptions.hasKey("environment") && rnOptions.getString("environment") != null) {
+      options.setEnvironment(rnOptions.getString("environment"));
+    }
+    if (rnOptions.hasKey("release") && rnOptions.getString("release") != null) {
+      options.setRelease(rnOptions.getString("release"));
+    }
+    if (rnOptions.hasKey("dist") && rnOptions.getString("dist") != null) {
+      options.setDist(rnOptions.getString("dist"));
+    }
+    if (rnOptions.hasKey("enableAutoSessionTracking")) {
+      options.setEnableAutoSessionTracking(rnOptions.getBoolean("enableAutoSessionTracking"));
+    }
+    if (rnOptions.hasKey("sessionTrackingIntervalMillis")) {
+      options.setSessionTrackingIntervalMillis(rnOptions.getInt("sessionTrackingIntervalMillis"));
+    }
+    if (rnOptions.hasKey("shutdownTimeout")) {
+      options.setShutdownTimeoutMillis(rnOptions.getInt("shutdownTimeout"));
+    }
+    if (rnOptions.hasKey("enableNdkScopeSync")) {
+      options.setEnableScopeSync(rnOptions.getBoolean("enableNdkScopeSync"));
+    }
+    if (rnOptions.hasKey("attachStacktrace")) {
+      options.setAttachStacktrace(rnOptions.getBoolean("attachStacktrace"));
+    }
+    if (rnOptions.hasKey("attachThreads")) {
+      // JS use top level stacktrace and android attaches Threads which hides them so
+      // by default we hide.
+      options.setAttachThreads(rnOptions.getBoolean("attachThreads"));
+    }
+    if (rnOptions.hasKey("attachScreenshot")) {
+      options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot"));
+    }
+    if (rnOptions.hasKey("attachViewHierarchy")) {
+      options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy"));
+    }
+    if (rnOptions.hasKey("sendDefaultPii")) {
+      options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii"));
+    }
+    if (rnOptions.hasKey("maxQueueSize")) {
+      options.setMaxQueueSize(rnOptions.getInt("maxQueueSize"));
+    }
+    if (rnOptions.hasKey("enableNdk")) {
+      options.setEnableNdk(rnOptions.getBoolean("enableNdk"));
+    }
+    if (rnOptions.hasKey("spotlight")) {
+      if (rnOptions.getType("spotlight") == ReadableType.Boolean) {
+        options.setEnableSpotlight(rnOptions.getBoolean("spotlight"));
+        options.setSpotlightConnectionUrl(rnOptions.getString("defaultSidecarUrl"));
+      } else if (rnOptions.getType("spotlight") == ReadableType.String) {
+        options.setEnableSpotlight(true);
+        options.setSpotlightConnectionUrl(rnOptions.getString("spotlight"));
+      }
+    }
+    if (rnOptions.hasKey("_experiments")) {
+      options.getExperimental().setSessionReplay(getReplayOptions(rnOptions));
+      options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter());
+    }
+    options.setBeforeSend(
+        (event, hint) -> {
+          // React native internally throws a JavascriptException
+          // Since we catch it before that, we don't want to send this one
+          // because we would send it twice
+          try {
+            SentryException ex = event.getExceptions().get(0);
+            if (null != ex && ex.getType().contains("JavascriptException")) {
+              return null;
             }
+          } catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
+            // We do nothing
           }
-          logger.log(
-              SentryLevel.INFO,
-              String.format("Native Integrations '%s'", options.getIntegrations()));
-
-          final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance();
-          final Activity currentActivity = getCurrentActivity();
-          if (currentActivity != null) {
-            currentActivityHolder.setActivity(currentActivity);
-          }
+
+          setEventOriginTag(event);
+          addPackages(event, options.getSdkVersion());
+
+          return event;
         });
 
-    promise.resolve(true);
+    if (rnOptions.hasKey("enableNativeCrashHandling")
+        && !rnOptions.getBoolean("enableNativeCrashHandling")) {
+      final List<Integration> integrations = options.getIntegrations();
+      for (final Integration integration : integrations) {
+        if (integration instanceof UncaughtExceptionHandlerIntegration
+            || integration instanceof AnrIntegration
+            || integration instanceof NdkIntegration) {
+          integrations.remove(integration);
+        }
+      }
+    }
+    logger.log(
+        SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations()));
+
+    final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance();
+    final Activity currentActivity = getCurrentActivity();
+    if (currentActivity != null) {
+      currentActivityHolder.setActivity(currentActivity);
+    }
   }
 
   private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) {
diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm
index 3009e46c09..98bb3e5ae8 100644
--- a/packages/core/ios/RNSentry.mm
+++ b/packages/core/ios/RNSentry.mm
@@ -161,6 +161,22 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)
             sentryOptions.integrations = integrations;
         }
     }
+    
+    // Set spotlight option
+    if ([mutableOptions valueForKey:@"spotlight"] != nil) {
+        id spotlightValue = [mutableOptions valueForKey:@"spotlight"];
+        if ([spotlightValue isKindOfClass:[NSString class]]) {
+            NSLog(@"Using Spotlight on address: %@", spotlightValue);
+            sentryOptions.enableSpotlight = true;
+            sentryOptions.spotlightUrl = spotlightValue;
+        } else if ([spotlightValue isKindOfClass:[NSNumber class]]) {
+            sentryOptions.enableSpotlight = [spotlightValue boolValue];
+            id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"];
+            if (defaultSpotlightUrl != nil) {
+                sentryOptions.spotlightUrl = defaultSpotlightUrl;
+            }
+        }
+    }
 
     // Enable the App start and Frames tracking measurements
     if ([mutableOptions valueForKey:@"enableAutoPerformanceTracing"] != nil) {
diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts
index f01f6d98b0..67e5ba6c5d 100644
--- a/packages/core/src/js/client.ts
+++ b/packages/core/src/js/client.ts
@@ -15,6 +15,7 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils';
 import { Alert } from 'react-native';
 
 import { defaultSdkInfo } from './integrations/sdkinfo';
+import { getDefaultSidecarUrl } from './integrations/spotlight';
 import type { ReactNativeClientOptions } from './options';
 import type { mobileReplayIntegration } from './replay/mobilereplay';
 import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay';
@@ -143,6 +144,7 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
   private _initNativeSdk(): void {
     NATIVE.initNativeSdk({
       ...this._options,
+      defaultSidecarUrl: getDefaultSidecarUrl(),
       mobileReplayOptions:
         this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] &&
         'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME]
diff --git a/packages/core/src/js/integrations/spotlight.ts b/packages/core/src/js/integrations/spotlight.ts
index 2116dd0cf8..9707e95026 100644
--- a/packages/core/src/js/integrations/spotlight.ts
+++ b/packages/core/src/js/integrations/spotlight.ts
@@ -83,7 +83,10 @@ function sendEnvelopesToSidecar(client: Client, sidecarUrl: string): void {
   });
 }
 
-function getDefaultSidecarUrl(): string {
+/**
+ * Gets the default Spotlight sidecar URL.
+ */
+export function getDefaultSidecarUrl(): string {
   try {
     const { url } = ReactNativeLibraries.Devtools?.getDevServer();
     return `http://${getHostnameFromString(url)}:8969/stream`;
diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts
index 577eaf99e6..f32652a0ce 100644
--- a/packages/core/src/js/wrapper.ts
+++ b/packages/core/src/js/wrapper.ts
@@ -50,6 +50,8 @@ export interface Screenshot {
 }
 
 export type NativeSdkOptions = Partial<ReactNativeClientOptions> & {
+  defaultSidecarUrl: string | undefined;
+} & {
   mobileReplayOptions: MobileReplayOptions | undefined;
 };