diff --git a/CHANGELOG.md b/CHANGELOG.md index abe5d363f0..bb8c861c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,59 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. -## Unreleased +## 6.7.0-alpha.0 + +### Features + +- Capture App Start errors and crashes by initializing Sentry from `sentry.options.json` ([#4472](https://github.com/getsentry/sentry-react-native/pull/4472)) + + Create `sentry.options.json` in the React Native project root and set options the same as you currently have in `Sentry.init` in JS. + + ```json + { + "dsn": "https://key@example.io/value", + } + ``` + + Initialize Sentry on the native layers by newly provided native methods. + + ```kotlin + import io.sentry.react.RNSentrySDK + + class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + RNSentrySDK.init(this) + } + } + ``` + + ```obj-c + #import + + @implementation AppDelegate + - (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions + { + [RNSentrySDK start]; + return [super application:application didFinishLaunchingWithOptions:launchOptions]; + } + @end + ``` + +### Changes + +- Load `optionsFile` into the JS bundle during Metro bundle process ([#4476](https://github.com/getsentry/sentry-react-native/pull/4476)) +- Add experimental version of `startWithConfigureOptions` for Apple platforms ([#4444](https://github.com/getsentry/sentry-react-native/pull/4444)) +- Add experimental version of `init` with optional `OptionsConfiguration` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) +- Add initialization using `sentry.options.json` for Apple platforms ([#4447](https://github.com/getsentry/sentry-react-native/pull/4447)) +- Add initialization using `sentry.options.json` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) +- Merge options from file with `Sentry.init` options in JS ([#4510](https://github.com/getsentry/sentry-react-native/pull/4510)) + +### Internal + +- Extract iOS native initialization to standalone structures ([#4442](https://github.com/getsentry/sentry-react-native/pull/4442)) +- Extract Android native initialization to standalone structures ([#4445](https://github.com/getsentry/sentry-react-native/pull/4445)) ### Dependencies diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index fd1a5e25c7..6b2c4e305c 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-e2e-tests", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "description": "Sentry React Native End to End Tests Library", "main": "dist/index.js", @@ -14,7 +14,7 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-typescript": "^7.18.6", "@sentry/core": "8.54.0", - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "@types/node": "^20.9.3", "@types/react": "^18.2.64", "appium": "2.4.1", diff --git a/dev-packages/type-check/package.json b/dev-packages/type-check/package.json index 6ed44c18a9..01cbef78bb 100644 --- a/dev-packages/type-check/package.json +++ b/dev-packages/type-check/package.json @@ -1,7 +1,7 @@ { "name": "sentry-react-native-type-check", "private": true, - "version": "6.6.0", + "version": "6.7.0-alpha.0", "scripts": { "type-check": "./run-type-check.sh" } diff --git a/dev-packages/utils/package.json b/dev-packages/utils/package.json index dc690d20e3..3eab86ea36 100644 --- a/dev-packages/utils/package.json +++ b/dev-packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-samples-utils", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "description": "Internal Samples Utils", "main": "index.js", "license": "MIT", diff --git a/lerna.json b/lerna.json index 44a86ffc11..bdcc106f79 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "packages": [ "packages/*", "dev-packages/*", diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec index 1d1852d109..c990e2fb63 100644 --- a/packages/core/RNSentry.podspec +++ b/packages/core/RNSentry.podspec @@ -33,7 +33,7 @@ Pod::Spec.new do |s| s.preserve_paths = '*.js' s.source_files = 'ios/**/*.{h,m,mm}' - s.public_header_files = 'ios/RNSentry.h' + s.public_header_files = 'ios/RNSentry.h', 'ios/RNSentrySDK.h' s.compiler_flags = other_cflags diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json new file mode 100644 index 0000000000..be3bb71111 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json @@ -0,0 +1,3 @@ +{ + "dsn": "invalid-dsn" +} \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt new file mode 100644 index 0000000000..f07bfaea41 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt @@ -0,0 +1 @@ +invalid-options \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json new file mode 100644 index 0000000000..f97a8df3f2 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json @@ -0,0 +1,5 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableTracing": true, + "tracesSampleRate": 1.0 +} \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt new file mode 100644 index 0000000000..e49aa546f8 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt @@ -0,0 +1,103 @@ +package io.sentry.react + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import io.sentry.react.RNSentryJsonConverter.convertToWritable +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RNSentryJsonConverterTest { + @Test + fun testConvertToWritableWithSimpleJsonObject() { + val jsonObject = + JSONObject().apply { + put("floatKey", 12.3f) + put("doubleKey", 12.3) + put("intKey", 123) + put("stringKey", "test") + put("nullKey", JSONObject.NULL) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + assertNotNull(result) + assertEquals(12.3, result!!.getDouble("floatKey"), 0.0001) + assertEquals(12.3, result.getDouble("doubleKey"), 0.0) + assertEquals(123, result.getInt("intKey")) + assertEquals("test", result.getString("stringKey")) + assertNull(result.getString("nullKey")) + } + + @Test + fun testConvertToWritableWithNestedJsonObject() { + val jsonObject = + JSONObject().apply { + put( + "nested", + JSONObject().apply { + put("key", "value") + }, + ) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + assertNotNull(result) + val nestedMap = result!!.getMap("nested") + assertNotNull(nestedMap) + assertEquals("value", nestedMap!!.getString("key")) + } + + @Test + fun testConvertToWritableWithJsonArray() { + val jsonArray = + JSONArray().apply { + put(1) + put(2.5) + put("string") + put(JSONObject.NULL) + } + + val result: WritableArray = convertToWritable(jsonArray) + + assertEquals(1, result.getInt(0)) + assertEquals(2.5, result.getDouble(1), 0.0) + assertEquals("string", result.getString(2)) + assertNull(result.getString(3)) + } + + @Test + fun testConvertToWritableWithNestedJsonArray() { + val jsonObject = + JSONObject().apply { + put( + "array", + JSONArray().apply { + put( + JSONObject().apply { + put("key1", "value1") + }, + ) + put( + JSONObject().apply { + put("key2", "value2") + }, + ) + }, + ) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + val array = result?.getArray("array") + assertEquals("value1", array?.getMap(0)?.getString("key1")) + assertEquals("value2", array?.getMap(1)?.getString("key2")) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt new file mode 100644 index 0000000000..3b95742e55 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt @@ -0,0 +1,200 @@ +package io.sentry.react + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.common.JavascriptException +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.Sentry +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.SentryEvent +import io.sentry.android.core.AndroidLogger +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.protocol.SdkVersion +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RNSentrySDKTest { + private val logger: ILogger = AndroidLogger(RNSentrySDKTest::class.java.simpleName) + private lateinit var context: Context + + companion object { + private const val INITIALISATION_ERROR = "Failed to initialize Sentry's React Native SDK" + private const val VALID_OPTIONS = "sentry.options.json" + private const val INVALID_OPTIONS = "invalid.options.json" + private const val INVALID_JSON = "invalid.options.txt" + private const val MISSING = "non-existing-file" + + private val validConfig = + OptionsConfiguration { options -> + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + private val invalidConfig = + OptionsConfiguration { options -> + options.dsn = "invalid-dsn" + } + private val emptyConfig = OptionsConfiguration {} + } + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + } + + @After + fun tearDown() { + Sentry.close() + } + + @Test + fun initialisesSuccessfullyWithDefaultValidJsonFile() { // sentry.options.json + RNSentrySDK.init(context) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndDefaultValidJsonFile() { + RNSentrySDK.init(context, validConfig) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndInvalidJsonFile() { + RNSentrySDK.init(context, validConfig, INVALID_OPTIONS, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndMissingJsonFile() { + RNSentrySDK.init(context, validConfig, MISSING, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndErrorInParsingJsonFile() { + RNSentrySDK.init(context, validConfig, INVALID_JSON, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithNoConfigurationAndValidJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithNoConfigurationAndInvalidJsonFile() { + try { + RNSentrySDK.init(context, emptyConfig, INVALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigAndInvalidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig, INVALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigAndValidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig, VALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigurationAndDefaultValidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun defaultsAndFinalsAreSetWithValidJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + verifyDefaults(actualOptions) + verifyFinals(actualOptions) + // options file + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + @Test + fun defaultsAndFinalsAreSetWithValidConfiguration() { + RNSentrySDK.init(context, validConfig, MISSING, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + verifyDefaults(actualOptions) + verifyFinals(actualOptions) + // configuration + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + @Test + fun defaultsOverrideOptionsJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + assertNull(actualOptions.tracesSampleRate) + assertEquals(false, actualOptions.enableTracing) + } + + @Test + fun configurationOverridesDefaultOptions() { + val validConfig = + OptionsConfiguration { options -> + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.tracesSampleRate = 0.5 + options.enableTracing = true + } + RNSentrySDK.init(context, validConfig, MISSING, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + assertEquals(0.5, actualOptions.tracesSampleRate) + assertEquals(true, actualOptions.enableTracing) + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + private fun verifyDefaults(actualOptions: SentryAndroidOptions) { + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) + assertEquals( + io.sentry.android.core.BuildConfig.VERSION_NAME, + actualOptions.sdkVersion?.version, + ) + val pack = actualOptions.sdkVersion?.packages?.first { it.name == RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME } + assertNotNull(pack) + assertEquals(RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION, pack?.version) + assertNull(actualOptions.tracesSampleRate) + assertNull(actualOptions.tracesSampler) + assertEquals(false, actualOptions.enableTracing) + } + + private fun verifyFinals(actualOptions: SentryAndroidOptions) { + val event = + SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + val result = actualOptions.beforeSend?.execute(event, Hint()) + assertNotNull(result) + assertEquals("android", result?.getTag("event.origin")) + assertEquals("java", result?.getTag("event.environment")) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt new file mode 100644 index 0000000000..699fd81ccb --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.react + +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.android.core.SentryAndroidOptions +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(JUnit4::class) +class RNSentryCompositeOptionsConfigurationTest { + @Test + fun `configure should call base and overriding configurations`() { + val baseConfig: OptionsConfiguration = mock() + val overridingConfig: OptionsConfiguration = mock() + + val compositeConfig = RNSentryCompositeOptionsConfiguration(baseConfig, overridingConfig) + val options = SentryAndroidOptions() + compositeConfig.configure(options) + + verify(baseConfig).configure(options) + verify(overridingConfig).configure(options) + } + + @Test + fun `configure should apply base configuration and override values`() { + val baseConfig = + OptionsConfiguration { options -> + options.dsn = "https://base-dsn@sentry.io" + options.isDebug = false + options.release = "some-release" + } + val overridingConfig = + OptionsConfiguration { options -> + options.dsn = "https://over-dsn@sentry.io" + options.isDebug = true + options.environment = "production" + } + + val compositeConfig = RNSentryCompositeOptionsConfiguration(baseConfig, overridingConfig) + val options = SentryAndroidOptions() + compositeConfig.configure(options) + + assert(options.dsn == "https://over-dsn@sentry.io") // overridden value + assert(options.isDebug) // overridden value + assert(options.release == "some-release") // base value not overridden + assert(options.environment == "production") // overridden value not in base + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt index adffbf78ad..34af996a76 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt @@ -3,20 +3,13 @@ package io.sentry.react import android.content.pm.PackageInfo import android.content.pm.PackageManager import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.WritableMap -import com.facebook.react.common.JavascriptException -import io.sentry.Breadcrumb import io.sentry.ILogger import io.sentry.SentryLevel -import io.sentry.android.core.SentryAndroidOptions import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -103,163 +96,4 @@ class RNSentryModuleImplTest { val capturedMap = writableMapCaptor.value assertEquals(false, capturedMap.getBoolean("has_fetched")) } - - @Test - fun `when the spotlight option is enabled, the spotlight SentryAndroidOption is set to true and the default url is used`() { - val options = - JavaOnlyMap.of( - "spotlight", - true, - "defaultSidecarUrl", - "http://localhost:8969/teststream", - ) - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assert(actualOptions.isEnableSpotlight) - assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) - } - - @Test - fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { - val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assert(actualOptions.isEnableSpotlight) - assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) - } - - @Test - fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { - val options = JavaOnlyMap.of("spotlight", false) - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assertFalse(actualOptions.isEnableSpotlight) - } - - @Test - fun `the JavascriptException is added to the ignoredExceptionsForType list on initialisation`() { - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, JavaOnlyMap.of(), logger) - assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) - } - - @Test - fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - "http://localhost:8081", - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "https://def.ingest.sentry.io/1234567") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertNull("Breadcrumb should be filtered out", result) - } - - @Test - fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() { - val mockDevServerUrl = "http://localhost:8081" - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - mockDevServerUrl, - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", mockDevServerUrl) - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertNull("Breadcrumb should be filtered out", result) - } - - @Test - fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() { - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - "http://localhost:8081", - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { - val options = SentryAndroidOptions() - module.getSentryAndroidOptions(options, JavaOnlyMap(), logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { - val options = SentryAndroidOptions() - val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { - val options = SentryAndroidOptions() - val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } } diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt new file mode 100644 index 0000000000..fa177159e5 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt @@ -0,0 +1,251 @@ +package io.sentry.react + +import android.app.Activity +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.common.JavascriptException +import io.sentry.Breadcrumb +import io.sentry.ILogger +import io.sentry.SentryEvent +import io.sentry.android.core.CurrentActivityHolder +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.protocol.SdkVersion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations + +@RunWith(JUnit4::class) +class RNSentryStartTest { + private lateinit var logger: ILogger + + private lateinit var activity: Activity + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + logger = mock(ILogger::class.java) + activity = mock(Activity::class.java) + } + + @Test + fun `when the spotlight option is enabled, the spotlight SentryAndroidOption is set to true and the default url is used`() { + val options = + JavaOnlyMap.of( + "spotlight", + true, + "defaultSidecarUrl", + "http://localhost:8969/teststream", + ) + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) + assert(actualOptions.isEnableSpotlight) + assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) + } + + @Test + fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { + val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) + assert(actualOptions.isEnableSpotlight) + assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) + } + + @Test + fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { + val options = JavaOnlyMap.of("spotlight", false) + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) + assertFalse(actualOptions.isEnableSpotlight) + } + + @Test + fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + "http://localhost:8081", + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "https://def.ingest.sentry.io/1234567") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertNull("Breadcrumb should be filtered out", result) + } + + @Test + fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() { + val mockDevServerUrl = "http://localhost:8081" + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + mockDevServerUrl, + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", mockDevServerUrl) + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertNull("Breadcrumb should be filtered out", result) + } + + @Test + fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + "http://localhost:8081", + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { + val options = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(options, JavaOnlyMap(), logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + } + + @Test + fun `the sdk version information is added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) + assertEquals( + io.sentry.android.core.BuildConfig.VERSION_NAME, + actualOptions.sdkVersion?.version, + ) + assertEquals(true, actualOptions.sdkVersion?.packages?.isNotEmpty()) + assertEquals( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + actualOptions.sdkVersion + ?.packages + ?.last() + ?.name, + ) + assertEquals( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION, + actualOptions.sdkVersion + ?.packages + ?.last() + ?.version, + ) + } + + @Test + fun `the tracing options are added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertNull(actualOptions.tracesSampleRate) + assertNull(actualOptions.tracesSampler) + assertEquals(false, actualOptions.enableTracing) + } + + @Test + fun `the current activity is added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertEquals(activity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `beforeSend callback that sets event tags is set with react finals`() { + val options = SentryAndroidOptions() + val event = + SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + + RNSentryStart.updateWithReactFinals(options) + val result = options.beforeSend?.execute(event, mock()) + + assertNotNull(result) + assertEquals("android", result?.getTag("event.origin")) + assertEquals("java", result?.getTag("event.environment")) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index f78b1be0e0..0d82e39ef1 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -11,6 +11,11 @@ 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; + 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */; }; + 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */; }; + 339C6C482D3FD9A700CA72ED /* invalid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C462D3FD91900CA72ED /* invalid.options.json */; }; + 339C6C492D3FD9A700CA72ED /* invalid.options.txt in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C452D3FD90200CA72ED /* invalid.options.txt */; }; + 339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; @@ -25,6 +30,9 @@ 332D33482CDBDC7300547D76 /* RNSentry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentry.h; path = ../ios/RNSentry.h; sourceTree = SOURCE_ROOT; }; 332D33492CDCC8E100547D76 /* RNSentryTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryTests.h; sourceTree = ""; }; 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; + 333B58A82D35BA93000F8D04 /* RNSentryStart.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryStart.h; path = ../ios/RNSentryStart.h; sourceTree = SOURCE_ROOT; }; + 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "RNSentryStart+Test.h"; path = "RNSentryCocoaTesterTests/RNSentryStart+Test.h"; sourceTree = SOURCE_ROOT; }; + 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentrySDK.h; path = ../ios/RNSentrySDK.h; sourceTree = SOURCE_ROOT; }; 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = ""; }; 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; @@ -35,6 +43,13 @@ 338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; }; 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; }; 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; }; + 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartTests.swift; sourceTree = ""; }; + 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryVersion.h; path = ../ios/RNSentryVersion.h; sourceTree = SOURCE_ROOT; }; + 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartFromFileTests.swift; sourceTree = ""; }; + 339C6C442D3FD62D00CA72ED /* RNSentrySDK+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentrySDK+Test.h"; sourceTree = ""; }; + 339C6C452D3FD90200CA72ED /* invalid.options.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = invalid.options.txt; sourceTree = ""; }; + 339C6C462D3FD91900CA72ED /* invalid.options.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = invalid.options.json; sourceTree = ""; }; + 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = valid.options.json; sourceTree = ""; }; 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryFramesTrackerListenerTests.m; sourceTree = ""; }; 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryFramesTrackerListenerTests.h; sourceTree = ""; }; 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; @@ -69,6 +84,7 @@ 3360896929524163007C7730 = { isa = PBXGroup; children = ( + 339C6C432D3FD41C00CA72ED /* TestAssets */, 33AFE0122B8F319000AAB120 /* RNSentry */, 3360899029524164007C7730 /* RNSentryCocoaTesterTests */, 3360897329524163007C7730 /* Products */, @@ -88,6 +104,8 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */, + 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */, @@ -113,9 +131,24 @@ path = Replay; sourceTree = ""; }; + 339C6C432D3FD41C00CA72ED /* TestAssets */ = { + isa = PBXGroup; + children = ( + 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */, + 339C6C462D3FD91900CA72ED /* invalid.options.json */, + 339C6C452D3FD90200CA72ED /* invalid.options.txt */, + ); + path = TestAssets; + sourceTree = ""; + }; 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 339C6C442D3FD62D00CA72ED /* RNSentrySDK+Test.h */, + 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */, + 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */, + 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */, + 333B58A82D35BA93000F8D04 /* RNSentryStart.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, @@ -146,6 +179,7 @@ 3360898929524164007C7730 /* Sources */, BB7D14838753E6599863899B /* Frameworks */, CC7959F3721CB3AD7CB6A047 /* [CP] Copy Pods Resources */, + 339C6C472D3FD99900CA72ED /* Resources */, ); buildRules = ( ); @@ -190,6 +224,19 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 339C6C472D3FD99900CA72ED /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 339C6C482D3FD9A700CA72ED /* invalid.options.json in Resources */, + 339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */, + 339C6C492D3FD9A700CA72ED /* invalid.options.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ 30F19D4E16BEEFEC68733838 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -239,7 +286,9 @@ files = ( AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */, 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, + 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, + 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index bc2bdd0304..08fddcbf8e 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -7,3 +7,6 @@ #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" +#import "RNSentrySDK+Test.h" +#import "RNSentryStart.h" +#import "RNSentryVersion.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h new file mode 100644 index 0000000000..fcdfe7872b --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h @@ -0,0 +1,8 @@ +#import "RNSentryStart.h" + +@interface +RNSentryStart (Test) + ++ (void)setEventOriginTag:(SentryEvent *)event; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift new file mode 100644 index 0000000000..e0269a5961 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift @@ -0,0 +1,115 @@ +import XCTest + +final class RNSentryStartFromFileTests: XCTestCase { + + func testNoThrowOnMissingOptionsFile() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getNonExistingOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNil(actualOptions.parsedDsn) + } + + func testNoThrowOnInvalidFileType() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getInvalidOptionsTypePath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNil(actualOptions.parsedDsn) + } + + func testNoThrowOnInvalidOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getInvalidOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNotNil(actualOptions.parsedDsn) + XCTAssertEqual(actualOptions.environment, "environment-from-invalid-file") + } + + func testLoadValidOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getValidOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNotNil(actualOptions.parsedDsn) + XCTAssertEqual(actualOptions.environment, "environment-from-valid-file") + } + + func testOptionsFromFileInConfigureOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getValidOptionsPath()) { options in + wasConfigurationCalled = true + XCTAssertEqual(options.environment, "environment-from-valid-file") + } + + XCTAssertTrue(wasConfigurationCalled) + } + + func testOptionsOverwrittenInConfigureOptions() { + RNSentrySDK.start(getValidOptionsPath()) { options in + options.environment = "new-environment" + } + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertEqual(actualOptions.environment, "new-environment") + } + + func getNonExistingOptionsPath() -> String { + return "/non-existing.options.json" + } + + func getInvalidOptionsTypePath() -> String { + guard let path = getTestBundle().path(forResource: "invalid.options", ofType: "txt") else { + fatalError("Could not get invalid type options path") + } + return path + } + + func getInvalidOptionsPath() -> String { + guard let path = getTestBundle().path(forResource: "invalid.options", ofType: "json") else { + fatalError("Could not get invalid options path") + } + return path + } + + func getValidOptionsPath() -> String { + guard let path = getTestBundle().path(forResource: "valid.options", ofType: "json") else { + fatalError("Could not get invalid options path") + } + return path + } + + func getTestBundle() -> Bundle { + let maybeBundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) + guard let bundle = maybeBundle else { + fatalError("Could not find test bundle") + } + return bundle + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift new file mode 100644 index 0000000000..b9d12200cf --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift @@ -0,0 +1,248 @@ +import XCTest + +final class RNSentryStartTests: XCTestCase { + + func testStartDoesNotThrowWithoutConfigure() { + RNSentrySDK.start(configureOptions: nil) + } + + func assertReactDefaults(_ actualOptions: Options?) { + XCTAssertFalse(actualOptions!.enableCaptureFailedRequests) + XCTAssertNil(actualOptions!.tracesSampleRate) + XCTAssertNil(actualOptions!.tracesSampler) + XCTAssertFalse(actualOptions!.enableTracing) + } + + func testStartSetsReactDeafults() { + var actualOptions: Options? + + RNSentrySDK.start { options in + actualOptions = options + } + + XCTAssertNotNil(actualOptions, "start have not provided default options or have not executed configure callback") + assertReactDefaults(actualOptions) + } + + func testAutoStartSetsReactDefaults() throws { + try startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + + let actualOptions = PrivateSentrySDKOnly.options + assertReactDefaults(actualOptions) + } + + func testStartEnablesHybridTracing() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + }, + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.enableAutoPerformanceTracing = true + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableAutoPerformanceTracing": true + ]) + } + ] + + // Test each implementation + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + XCTAssertTrue(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) + XCTAssertTrue(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + } + } + + func testStartDisablesHybridTracing() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.enableAutoPerformanceTracing = false + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableAutoPerformanceTracing": false + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + XCTAssertFalse(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) + XCTAssertFalse(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + } + } + + func testStartIgnoresUnhandledJsExceptions() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + let actualEvent = actualOptions.beforeSend!(createUnhandledJsExceptionEvent()) + + XCTAssertNil(actualEvent) + } + } + + func testStartSetsNativeEventOrigin() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + let actualEvent = actualOptions.beforeSend!(createNativeEvent()) + + XCTAssertNotNil(actualEvent) + XCTAssertNotNil(actualEvent!.tags) + XCTAssertEqual(actualEvent!.tags!["event.origin"], "ios") + XCTAssertEqual(actualEvent!.tags!["event.environment"], "native") + } + } + + func testStartDoesNotOverwriteUserBeforeSend() { + var executed = false + + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.beforeSend = { event in + executed = true + return event + } + } + + PrivateSentrySDKOnly.options.beforeSend!(genericEvent()) + + XCTAssertTrue(executed) + } + + func testStartSetsHybridSdkName() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualEvent = captuteTestEvent() + + XCTAssertNotNil(actualEvent) + XCTAssertNotNil(actualEvent!.sdk) + XCTAssertEqual(actualEvent!.sdk!["name"] as! String, NATIVE_SDK_NAME) + + let packages = actualEvent!.sdk!["packages"] as! [[String: String]] + let reactPackage = packages.first { $0["name"] == REACT_NATIVE_SDK_PACKAGE_NAME } + + XCTAssertNotNil(reactPackage) + XCTAssertEqual(reactPackage!["name"], REACT_NATIVE_SDK_PACKAGE_NAME) + XCTAssertEqual(reactPackage!["version"], REACT_NATIVE_SDK_PACKAGE_VERSION) + } + } + + func startFromRN(options: [String: Any]) throws { + var error: NSError? + RNSentryStart.start(options: options, error: &error) + + if let error = error { + throw error + } + } + + func createUnhandledJsExceptionEvent() -> Event { + let event = Event() + event.exceptions = [] + event.exceptions!.append(Exception(value: "Test", type: "Unhandled JS Exception: undefined is not a function")) + return event + } + + func createNativeEvent() -> Event { + let event = Event() + event.sdk = [ + "name": NATIVE_SDK_NAME, + "version": "1.2.3" + ] + return event + } + + func genericEvent() -> Event { + return Event() + } + + func captuteTestEvent() -> Event? { + var actualEvent: Event? + + // This is the closest to the sent event we can get using the actual Sentry start method + let originalBeforeSend = PrivateSentrySDKOnly.options.beforeSend + PrivateSentrySDKOnly.options.beforeSend = { event in + if let originalBeforeSend = originalBeforeSend { + let processedEvent = originalBeforeSend(event) + actualEvent = processedEvent + return processedEvent + } + actualEvent = event + return event + } + + SentrySDK.capture(message: "Test") + + return actualEvent + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm index 6e63793b85..abe2ae70ce 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm @@ -1,6 +1,8 @@ #import "RNSentryTests.h" +#import "RNSentryStart+Test.h" #import #import +#import #import #import #import @@ -11,9 +13,8 @@ @interface RNSentryInitNativeSdkTests : XCTestCase @implementation RNSentryInitNativeSdkTests -- (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties +- (void)testStartWithDictionaryRemovesPerformanceProperties { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @@ -25,9 +26,8 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties , @"enableTracing" : @YES, } ; -SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; - +[RNSentryStart startWithOptions:mockedReactNativeDictionary error:&error]; +SentryOptions *actualOptions = PrivateSentrySDKOnly.options; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertNotNil( @@ -40,14 +40,13 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties - (void)testCaptureFailedRequestsIsDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + [RNSentryStart startWithOptions:mockedReactNativeDictionary error:&error]; + SentryOptions *actualOptions = PrivateSentrySDKOnly.options; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); @@ -56,14 +55,13 @@ - (void)testCaptureFailedRequestsIsDisabled - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDefault { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], true, @@ -72,14 +70,13 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDefault - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDefault { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual( @@ -88,15 +85,14 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDefault - (void)testCreateOptionsWithDictionaryNativeCrashHandlingEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableNativeCrashHandling" : @YES, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], true, @@ -105,15 +101,14 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingEnabled - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableAutoPerformanceTracing" : @YES, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual( @@ -122,15 +117,14 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingEnabled - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableNativeCrashHandling" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], false, @@ -139,15 +133,14 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDisabled - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableAutoPerformanceTracing" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual(actualOptions.enableAutoPerformanceTracing, false, @@ -156,7 +149,6 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled - (void)testCreateOptionsWithDictionarySpotlightEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @@ -164,8 +156,8 @@ - (void)testCreateOptionsWithDictionarySpotlightEnabled @"spotlight" : @YES, @"defaultSidecarUrl" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart 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"); @@ -174,7 +166,6 @@ - (void)testCreateOptionsWithDictionarySpotlightEnabled - (void)testCreateOptionsWithDictionarySpotlightOne { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @@ -182,8 +173,8 @@ - (void)testCreateOptionsWithDictionarySpotlightOne @"spotlight" : @1, @"defaultSidecarUrl" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart 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"); @@ -192,15 +183,14 @@ - (void)testCreateOptionsWithDictionarySpotlightOne - (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]; + SentryOptions *actualOptions = + [RNSentryStart 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"); @@ -209,15 +199,14 @@ - (void)testCreateOptionsWithDictionarySpotlightUrl - (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]; + SentryOptions *actualOptions = + [RNSentryStart 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"); @@ -225,15 +214,14 @@ - (void)testCreateOptionsWithDictionarySpotlightDisabled - (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]; + SentryOptions *actualOptions = + [RNSentryStart 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"); @@ -241,14 +229,13 @@ - (void)testCreateOptionsWithDictionarySpotlightZero - (void)testPassesErrorOnWrongDsn { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"not_a_valid_dsn", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNil(actualOptions, @"Created invalid sentry options"); XCTAssertNotNil(error, @"Did not created error on invalid dsn"); @@ -256,14 +243,14 @@ - (void)testPassesErrorOnWrongDsn - (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : @"http://localhost:8081" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -276,14 +263,14 @@ - (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs - (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSString *mockDevServer = @"http://localhost:8081"; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : mockDevServer }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -296,14 +283,14 @@ - (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs - (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : @"http://localhost:8081" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -316,13 +303,13 @@ - (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBr - (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedAndDsnDoesNotMatch { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ // dsn is always validated in SentryOptions initialization @"dsn" : @"https://abc@def.ingest.sentry.io/1234567" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -335,13 +322,12 @@ - (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedA - (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags { - RNSentry *rnSentry = [[RNSentry alloc] init]; SentryEvent *testEvent = [[SentryEvent alloc] init]; testEvent.sdk = @{ @"name" : @"sentry.cocoa.react-native", }; - [rnSentry setEventOriginTag:testEvent]; + [RNSentryStart setEventOriginTag:testEvent]; XCTAssertEqual(testEvent.tags[@"event.origin"], @"ios"); XCTAssertEqual(testEvent.tags[@"event.environment"], @"native"); @@ -349,7 +335,6 @@ - (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags - (void)testEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritten { - RNSentry *rnSentry = [[RNSentry alloc] init]; SentryEvent *testEvent = [[SentryEvent alloc] init]; testEvent.sdk = @{ @"name" : @"sentry.cocoa.react-native", @@ -359,7 +344,7 @@ - (void)testEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritten @"event.environment" : @"testEventEnvironmentTag", }; - [rnSentry setEventOriginTag:testEvent]; + [RNSentryStart setEventOriginTag:testEvent]; XCTAssertEqual(testEvent.tags[@"event.origin"], @"ios"); XCTAssertEqual(testEvent.tags[@"event.environment"], @"native"); diff --git a/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h b/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h new file mode 100644 index 0000000000..06da31b42d --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h @@ -0,0 +1,9 @@ +#import "RNSentrySDK.h" + +@interface +RNSentrySDK (Test) + ++ (void)start:(NSString *)path + configureOptions:(void (^)(SentryOptions *_Nonnull options))configureOptions; + +@end diff --git a/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json new file mode 100644 index 0000000000..bf8f2be64c --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json @@ -0,0 +1,5 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "environment": "environment-from-invalid-file", + "invalid-option": 123 +} diff --git a/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt new file mode 100644 index 0000000000..601553b507 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt @@ -0,0 +1 @@ +invalid-options diff --git a/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json b/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json new file mode 100644 index 0000000000..641087d5e8 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json @@ -0,0 +1,4 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "environment": "environment-from-valid-file" +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java new file mode 100644 index 0000000000..0069abb660 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java @@ -0,0 +1,25 @@ +package io.sentry.react; + +import io.sentry.Sentry.OptionsConfiguration; +import io.sentry.android.core.SentryAndroidOptions; +import java.util.List; +import org.jetbrains.annotations.NotNull; + +class RNSentryCompositeOptionsConfiguration implements OptionsConfiguration { + private final @NotNull List> configurations; + + @SafeVarargs + protected RNSentryCompositeOptionsConfiguration( + @NotNull OptionsConfiguration... configurations) { + this.configurations = List.of(configurations); + } + + @Override + public void configure(@NotNull SentryAndroidOptions options) { + for (OptionsConfiguration configuration : configurations) { + if (configuration != null) { + configuration.configure(options); + } + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java new file mode 100644 index 0000000000..44ec324eed --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java @@ -0,0 +1,76 @@ +package io.sentry.react; + +import com.facebook.react.bridge.JavaOnlyArray; +import com.facebook.react.bridge.JavaOnlyMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import java.util.Iterator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +final class RNSentryJsonConverter { + public static final String NAME = "RNSentry.RNSentryJsonConverter"; + + private static final ILogger logger = new AndroidLogger(NAME); + + private RNSentryJsonConverter() { + throw new AssertionError("Utility class should not be instantiated"); + } + + @Nullable + static WritableMap convertToWritable(@NotNull JSONObject jsonObject) { + try { + WritableMap writableMap = new JavaOnlyMap(); + Iterator iterator = jsonObject.keys(); + while (iterator.hasNext()) { + String key = iterator.next(); + Object value = jsonObject.get(key); + if (value instanceof Float || value instanceof Double) { + writableMap.putDouble(key, jsonObject.getDouble(key)); + } else if (value instanceof Number) { + writableMap.putInt(key, jsonObject.getInt(key)); + } else if (value instanceof String) { + writableMap.putString(key, jsonObject.getString(key)); + } else if (value instanceof JSONObject) { + writableMap.putMap(key, convertToWritable(jsonObject.getJSONObject(key))); + } else if (value instanceof JSONArray) { + writableMap.putArray(key, convertToWritable(jsonObject.getJSONArray(key))); + } else if (value == JSONObject.NULL) { + writableMap.putNull(key); + } + } + return writableMap; + } catch (JSONException e) { + logger.log(SentryLevel.ERROR, "Error parsing json object:" + e.getMessage()); + return null; + } + } + + @NotNull + static WritableArray convertToWritable(@NotNull JSONArray jsonArray) throws JSONException { + WritableArray writableArray = new JavaOnlyArray(); + for (int i = 0; i < jsonArray.length(); i++) { + Object value = jsonArray.get(i); + if (value instanceof Float || value instanceof Double) { + writableArray.pushDouble(jsonArray.getDouble(i)); + } else if (value instanceof Number) { + writableArray.pushInt(jsonArray.getInt(i)); + } else if (value instanceof String) { + writableArray.pushString(jsonArray.getString(i)); + } else if (value instanceof JSONObject) { + writableArray.pushMap(convertToWritable(jsonArray.getJSONObject(i))); + } else if (value instanceof JSONArray) { + writableArray.pushArray(convertToWritable(jsonArray.getJSONArray(i))); + } else if (value == JSONObject.NULL) { + writableArray.pushNull(); + } + } + return writableArray; + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java new file mode 100644 index 0000000000..9c7cf5d3ff --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java @@ -0,0 +1,41 @@ +package io.sentry.react; + +import android.content.Context; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; + +final class RNSentryJsonUtils { + private RNSentryJsonUtils() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static @Nullable JSONObject getOptionsFromConfigurationFile( + @NotNull Context context, @NotNull String fileName, @NotNull ILogger logger) { + try (InputStream inputStream = context.getAssets().open(fileName); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + String configFileContent = stringBuilder.toString(); + return new JSONObject(configFileContent); + + } catch (Exception e) { + logger.log( + SentryLevel.ERROR, + "Failed to read configuration file. Please make sure " + + fileName + + " exists in the root of your project.", + e); + return null; + } + } +} 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 486ae72c48..3ffc3eb617 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,13 +20,11 @@ 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; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.common.JavascriptException; import com.facebook.react.modules.core.DeviceEventManagerModule; import io.sentry.Breadcrumb; import io.sentry.HubAdapter; @@ -34,25 +32,16 @@ import io.sentry.IScope; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; -import io.sentry.Integration; import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; -import io.sentry.SentryEvent; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.SentryReplayOptions; -import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.AndroidProfiler; -import io.sentry.android.core.AnrIntegration; -import io.sentry.android.core.BuildConfig; import io.sentry.android.core.BuildInfoProvider; -import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.InternalSentrySdk; -import io.sentry.android.core.NdkIntegration; -import io.sentry.android.core.SentryAndroid; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.ViewHierarchyEventProcessor; @@ -61,11 +50,8 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; -import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; -import io.sentry.react.replay.RNSentryReplayMask; -import io.sentry.react.replay.RNSentryReplayUnmask; import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; @@ -77,8 +63,6 @@ import java.io.FileReader; import java.io.IOException; import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Iterator; @@ -178,216 +162,12 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) { } public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { - SentryAndroid.init( - this.getReactApplicationContext(), - options -> getSentryAndroidOptions(options, rnOptions, logger)); + RNSentryStart.startWithOptions( + this.getReactApplicationContext(), rnOptions, getCurrentActivity(), logger); promise.resolve(true); } - protected void getSentryAndroidOptions( - @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable SdkVersion sdkVersion = options.getSdkVersion(); - if (sdkVersion == null) { - sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); - } else { - sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); - } - sdkVersion.addPackage( - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - - options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); - options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); - - 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")); - } - } - - SentryReplayOptions replayOptions = getReplayOptions(rnOptions); - options.setSessionReplay(replayOptions); - if (isReplayEnabled(replayOptions)) { - options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); - } - - // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - String dsn = getURLFromDSN(rnOptions.getString("dsn")); - String devServerUrl = rnOptions.getString("devServerUrl"); - options.setBeforeBreadcrumb( - (breadcrumb, hint) -> { - Object urlObject = breadcrumb.getData("url"); - String url = urlObject instanceof String ? (String) urlObject : ""; - if ("http".equals(breadcrumb.getType()) - && ((dsn != null && url.startsWith(dsn)) - || (devServerUrl != null && url.startsWith(devServerUrl)))) { - return null; - } - return breadcrumb; - }); - - // React native internally throws a JavascriptException. - // we want to ignore it on the native side to avoid sending it twice. - options.addIgnoredExceptionForType(JavascriptException.class); - - options.setBeforeSend( - (event, hint) -> { - setEventOriginTag(event); - addPackages(event, options.getSdkVersion()); - - return event; - }); - - if (rnOptions.hasKey("enableNativeCrashHandling") - && !rnOptions.getBoolean("enableNativeCrashHandling")) { - final List 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 boolean isReplayEnabled(SentryReplayOptions replayOptions) { - return replayOptions.getSessionSampleRate() != null - || replayOptions.getOnErrorSampleRate() != null; - } - - private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = - new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - @NotNull - final SentryReplayOptions androidReplayOptions = - new SentryReplayOptions(false, replaySdkVersion); - - if (!(rnOptions.hasKey("replaysSessionSampleRate") - || rnOptions.hasKey("replaysOnErrorSampleRate"))) { - return androidReplayOptions; - } - - androidReplayOptions.setSessionSampleRate( - rnOptions.hasKey("replaysSessionSampleRate") - ? rnOptions.getDouble("replaysSessionSampleRate") - : null); - androidReplayOptions.setOnErrorSampleRate( - rnOptions.hasKey("replaysOnErrorSampleRate") - ? rnOptions.getDouble("replaysOnErrorSampleRate") - : null); - - if (!rnOptions.hasKey("mobileReplayOptions")) { - return androidReplayOptions; - } - @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); - if (rnMobileReplayOptions == null) { - return androidReplayOptions; - } - - androidReplayOptions.setMaskAllText( - !rnMobileReplayOptions.hasKey("maskAllText") - || rnMobileReplayOptions.getBoolean("maskAllText")); - androidReplayOptions.setMaskAllImages( - !rnMobileReplayOptions.hasKey("maskAllImages") - || rnMobileReplayOptions.getBoolean("maskAllImages")); - - final boolean redactVectors = - !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); - if (redactVectors) { - androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg - } - - androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); - androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); - - return androidReplayOptions; - } - public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } @@ -974,51 +754,6 @@ public void crashedLastRun(Promise promise) { promise.resolve(Sentry.isCrashedLastRun()); } - private void setEventOriginTag(SentryEvent event) { - // We hardcode native-java as only java events are processed by the Android SDK. - SdkVersion sdk = event.getSdk(); - if (sdk != null) { - switch (sdk.getName()) { - case RNSentryVersion.NATIVE_SDK_NAME: - setEventEnvironmentTag(event, "native"); - break; - case RNSentryVersion.ANDROID_SDK_NAME: - setEventEnvironmentTag(event, "java"); - break; - default: - break; - } - } - } - - private void setEventEnvironmentTag(SentryEvent event, String environment) { - event.setTag("event.origin", "android"); - event.setTag("event.environment", environment); - } - - private void addPackages(SentryEvent event, SdkVersion sdk) { - SdkVersion eventSdk = event.getSdk(); - if (eventSdk != null - && "sentry.javascript.react-native".equals(eventSdk.getName()) - && sdk != null) { - List sentryPackages = sdk.getPackages(); - if (sentryPackages != null) { - for (SentryPackage sentryPackage : sentryPackages) { - eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); - } - } - - List integrations = sdk.getIntegrations(); - if (integrations != null) { - for (String integration : integrations) { - eventSdk.addIntegration(integration); - } - } - - event.setSdk(eventSdk); - } - } - private boolean checkAndroidXAvailability() { try { Class.forName("androidx.core.app.FrameMetricsAggregator"); @@ -1032,17 +767,4 @@ private boolean checkAndroidXAvailability() { private boolean isFrameMetricsAggregatorAvailable() { return androidXAvailable && frameMetricsAggregator != null; } - - public static @Nullable String getURLFromDSN(@Nullable String dsn) { - if (dsn == null) { - return null; - } - URI uri = null; - try { - uri = new URI(dsn); - } catch (URISyntaxException e) { - return null; - } - return uri.getScheme() + "://" + uri.getHost(); - } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java b/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java new file mode 100644 index 0000000000..ca219351fe --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java @@ -0,0 +1,68 @@ +package io.sentry.react; + +import android.content.Context; +import com.facebook.react.bridge.ReadableMap; +import io.sentry.ILogger; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.SentryAndroidOptions; +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; + +public final class RNSentrySDK { + private static final String CONFIGURATION_FILE = "sentry.options.json"; + private static final String NAME = "RNSentrySDK"; + + private static final ILogger logger = new AndroidLogger(NAME); + + private RNSentrySDK() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static void init( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration, + @NotNull String configurationFile, + @NotNull ILogger logger) { + try { + JSONObject jsonObject = + RNSentryJsonUtils.getOptionsFromConfigurationFile(context, configurationFile, logger); + if (jsonObject == null) { + RNSentryStart.startWithConfiguration(context, configuration); + return; + } + ReadableMap rnOptions = RNSentryJsonConverter.convertToWritable(jsonObject); + if (rnOptions == null) { + RNSentryStart.startWithConfiguration(context, configuration); + return; + } + RNSentryStart.startWithOptions(context, rnOptions, configuration, logger); + } catch (Exception e) { + logger.log( + SentryLevel.ERROR, "Failed to start Sentry with options from configuration file.", e); + throw new RuntimeException("Failed to initialize Sentry's React Native SDK", e); + } + } + + /** + * @experimental Start the Native Android SDK with the provided configuration options. Uses as a + * base configurations the `sentry.options.json` configuration file if it exists. + * @param context Android Context + * @param configuration configuration options + */ + public static void init( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration) { + init(context, configuration, CONFIGURATION_FILE, logger); + } + + /** + * @experimental Start the Native Android SDK with options from `sentry.options.json` + * configuration file. + * @param context Android Context + */ + public static void init(@NotNull final Context context) { + init(context, options -> {}, CONFIGURATION_FILE, logger); + } +} 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 new file mode 100644 index 0000000000..86699ced05 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -0,0 +1,365 @@ +package io.sentry.react; + +import android.app.Activity; +import android.content.Context; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.common.JavascriptException; +import io.sentry.ILogger; +import io.sentry.Integration; +import io.sentry.Sentry; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions.BeforeSendCallback; +import io.sentry.SentryReplayOptions; +import io.sentry.UncaughtExceptionHandlerIntegration; +import io.sentry.android.core.AnrIntegration; +import io.sentry.android.core.BuildConfig; +import io.sentry.android.core.CurrentActivityHolder; +import io.sentry.android.core.NdkIntegration; +import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryPackage; +import io.sentry.react.replay.RNSentryReplayMask; +import io.sentry.react.replay.RNSentryReplayUnmask; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class RNSentryStart { + + private RNSentryStart() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static void startWithConfiguration( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, null); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + defaults, configuration, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void startWithOptions( + @NotNull final Context context, + @NotNull final ReadableMap rnOptions, + @NotNull Sentry.OptionsConfiguration configuration, + @NotNull ILogger logger) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, null); + Sentry.OptionsConfiguration rnConfigurationOptions = + options -> getSentryAndroidOptions(options, rnOptions, logger); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + rnConfigurationOptions, defaults, configuration, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void startWithOptions( + @NotNull final Context context, + @NotNull final ReadableMap rnOptions, + @Nullable Activity currentActivity, + @NotNull ILogger logger) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, currentActivity); + Sentry.OptionsConfiguration rnConfigurationOptions = + options -> getSentryAndroidOptions(options, rnOptions, logger); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + rnConfigurationOptions, defaults, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void getSentryAndroidOptions( + @NotNull SentryAndroidOptions options, + @NotNull ReadableMap rnOptions, + @NotNull ILogger 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("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")); + } + } + + SentryReplayOptions replayOptions = getReplayOptions(rnOptions); + options.setSessionReplay(replayOptions); + if (isReplayEnabled(replayOptions)) { + options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); + } + + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + String dsn = getURLFromDSN(rnOptions.getString("dsn")); + String devServerUrl = rnOptions.getString("devServerUrl"); + options.setBeforeBreadcrumb( + (breadcrumb, hint) -> { + Object urlObject = breadcrumb.getData("url"); + String url = urlObject instanceof String ? (String) urlObject : ""; + if ("http".equals(breadcrumb.getType()) + && ((dsn != null && url.startsWith(dsn)) + || (devServerUrl != null && url.startsWith(devServerUrl)))) { + return null; + } + return breadcrumb; + }); + + if (rnOptions.hasKey("enableNativeCrashHandling") + && !rnOptions.getBoolean("enableNativeCrashHandling")) { + final List 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())); + } + + /** + * This function updates the options with RNSentry defaults. These default can be overwritten by + * users during manual native initialization. + */ + static void updateWithReactDefaults( + @NotNull SentryAndroidOptions options, @Nullable Activity currentActivity) { + @Nullable SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion == null) { + sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); + } else { + sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); + } + sdkVersion.addPackage( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + + options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); + options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); + options.setSdkVersion(sdkVersion); + + // Tracing is only enabled in JS to avoid duplicate navigation spans + options.setTracesSampleRate(null); + options.setTracesSampler(null); + options.setEnableTracing(false); + + // React native internally throws a JavascriptException. + // we want to ignore it on the native side to avoid sending it twice. + options.addIgnoredExceptionForType(JavascriptException.class); + + setCurrentActivity(currentActivity); + } + + /** + * This function updates options with changes RNSentry users should not change and so this is + * applied after the configureOptions callback during manual native initialization. + */ + static void updateWithReactFinals(@NotNull SentryAndroidOptions options) { + BeforeSendCallback userBeforeSend = options.getBeforeSend(); + options.setBeforeSend( + (event, hint) -> { + setEventOriginTag(event); + addPackages(event, options.getSdkVersion()); + if (userBeforeSend != null) { + return userBeforeSend.execute(event, hint); + } + return event; + }); + } + + private static void setCurrentActivity(Activity currentActivity) { + final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); + if (currentActivity != null) { + currentActivityHolder.setActivity(currentActivity); + } + } + + private static boolean isReplayEnabled(SentryReplayOptions replayOptions) { + return replayOptions.getSessionSampleRate() != null + || replayOptions.getOnErrorSampleRate() != null; + } + + private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { + final SdkVersion replaySdkVersion = + new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + @NotNull + final SentryReplayOptions androidReplayOptions = + new SentryReplayOptions(false, replaySdkVersion); + + if (!(rnOptions.hasKey("replaysSessionSampleRate") + || rnOptions.hasKey("replaysOnErrorSampleRate"))) { + return androidReplayOptions; + } + + androidReplayOptions.setSessionSampleRate( + rnOptions.hasKey("replaysSessionSampleRate") + ? rnOptions.getDouble("replaysSessionSampleRate") + : null); + androidReplayOptions.setOnErrorSampleRate( + rnOptions.hasKey("replaysOnErrorSampleRate") + ? rnOptions.getDouble("replaysOnErrorSampleRate") + : null); + + if (!rnOptions.hasKey("mobileReplayOptions")) { + return androidReplayOptions; + } + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + if (rnMobileReplayOptions == null) { + return androidReplayOptions; + } + + androidReplayOptions.setMaskAllText( + !rnMobileReplayOptions.hasKey("maskAllText") + || rnMobileReplayOptions.getBoolean("maskAllText")); + androidReplayOptions.setMaskAllImages( + !rnMobileReplayOptions.hasKey("maskAllImages") + || rnMobileReplayOptions.getBoolean("maskAllImages")); + + final boolean redactVectors = + !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); + if (redactVectors) { + androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg + } + + androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); + androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); + + return androidReplayOptions; + } + + private static void setEventOriginTag(SentryEvent event) { + // We hardcode native-java as only java events are processed by the Android SDK. + SdkVersion sdk = event.getSdk(); + if (sdk != null) { + switch (sdk.getName()) { + case RNSentryVersion.NATIVE_SDK_NAME: + setEventEnvironmentTag(event, "native"); + break; + case RNSentryVersion.ANDROID_SDK_NAME: + setEventEnvironmentTag(event, "java"); + break; + default: + break; + } + } + } + + private static void setEventEnvironmentTag(SentryEvent event, String environment) { + event.setTag("event.origin", "android"); + event.setTag("event.environment", environment); + } + + private static void addPackages(SentryEvent event, SdkVersion sdk) { + SdkVersion eventSdk = event.getSdk(); + if (eventSdk != null + && "sentry.javascript.react-native".equals(eventSdk.getName()) + && sdk != null) { + List sentryPackages = sdk.getPackages(); + if (sentryPackages != null) { + for (SentryPackage sentryPackage : sentryPackages) { + eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); + } + } + + List integrations = sdk.getIntegrations(); + if (integrations != null) { + for (String integration : integrations) { + eventSdk.addIntegration(integration); + } + } + + event.setSdk(eventSdk); + } + } + + private static @Nullable String getURLFromDSN(@Nullable String dsn) { + if (dsn == null) { + return null; + } + URI uri = null; + try { + uri = new URI(dsn); + } catch (URISyntaxException e) { + return null; + } + return uri.getScheme() + "://" + uri.getHost(); + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java index 23b1b258ed..b9a3d71bb7 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java @@ -2,7 +2,7 @@ class RNSentryVersion { static final String REACT_NATIVE_SDK_PACKAGE_NAME = "npm:@sentry/react-native"; - static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.6.0"; + static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.7.0-alpha.0"; static final String NATIVE_SDK_NAME = "sentry.native.android.react-native"; static final String ANDROID_SDK_NAME = "sentry.java.android.react-native"; static final String REACT_NATIVE_SDK_NAME = "sentry.javascript.react-native"; diff --git a/packages/core/ios/RNSentry.h b/packages/core/ios/RNSentry.h index cfd0b74b28..c7fb93e0ea 100644 --- a/packages/core/ios/RNSentry.h +++ b/packages/core/ios/RNSentry.h @@ -11,6 +11,9 @@ #import #import +// This import exposes public RNSentrySDK start +#import "RNSentrySDK.h" + typedef int (*SymbolicateCallbackType)(const void *, Dl_info *); @interface @@ -20,11 +23,6 @@ SentrySDK (Private) @interface RNSentry : RCTEventEmitter -- (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options - error:(NSError *_Nullable *_Nonnull)errorPointer; - -- (void)setEventOriginTag:(SentryEvent *)event; - - (NSDictionary *_Nonnull)fetchNativeStackFramesBy:(NSArray *)instructionsAddr symbolicate:(SymbolicateCallbackType)symbolicate; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 79ff76d0ae..69fd287403 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -49,6 +49,7 @@ # import "RNSentryRNSScreen.h" #endif +#import "RNSentryStart.h" #import "RNSentryVersion.h" @interface @@ -63,7 +64,6 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; static bool hasFetchedAppStart; @implementation RNSentry { - bool sentHybridSdkDidBecomeActive; bool hasListeners; RNSentryTimeToDisplay *_timeToDisplay; } @@ -94,181 +94,14 @@ - (instancetype)init : (RCTPromiseRejectBlock)reject) { NSError *error = nil; - SentryOptions *sentryOptions = [self createOptionsWithDictionary:options error:&error]; + [RNSentryStart startWithOptions:options error:&error]; if (error != nil) { reject(@"SentryReactNative", error.localizedDescription, error); return; } - - NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; - [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; - [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME - version:REACT_NATIVE_SDK_PACKAGE_VERSION]; - - [SentrySDK startWithOptions:sentryOptions]; - -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - BOOL appIsActive = - [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; -#else - BOOL appIsActive = [[NSApplication sharedApplication] isActive]; -#endif - - // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive - // notification, send it. - if (appIsActive && !sentHybridSdkDidBecomeActive - && (PrivateSentrySDKOnly.options.enableAutoSessionTracking - || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { - [[NSNotificationCenter defaultCenter] postNotificationName:@"SentryHybridSdkDidBecomeActive" - object:nil]; - - sentHybridSdkDidBecomeActive = true; - } - -#if SENTRY_TARGET_REPLAY_SUPPORTED - [RNSentryReplay postInit]; -#endif - resolve(@YES); } -- (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options - error:(NSError *_Nonnull *_Nonnull)errorPointer -{ - SentryBeforeSendEventCallback beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception of - // react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - return nil; - } - - [self setEventOriginTag:event]; - - return event; - }; - - NSMutableDictionary *mutableOptions = [options mutableCopy]; - [mutableOptions setValue:beforeSend forKey:@"beforeSend"]; - - // remove performance traces sample rate and traces sampler since we don't want to synchronize - // these configurations to the Native SDKs. The user could tho initialize the SDK manually and - // set themselves. - [mutableOptions removeObjectForKey:@"tracesSampleRate"]; - [mutableOptions removeObjectForKey:@"tracesSampler"]; - [mutableOptions removeObjectForKey:@"enableTracing"]; - -#if SENTRY_TARGET_REPLAY_SUPPORTED - [RNSentryReplay updateOptions:mutableOptions]; -#endif - - SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions - didFailWithError:errorPointer]; - if (*errorPointer != nil) { - return nil; - } - - // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; - NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; - sentryOptions.beforeBreadcrumb - = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) - { - NSString *url = breadcrumb.data[@"url"] ?: @""; - - if ([@"http" isEqualToString:breadcrumb.type] - && ((dsn != nil && [url hasPrefix:dsn]) - || (devServerUrl != nil && [url hasPrefix:devServerUrl]))) { - return nil; - } - return breadcrumb; - }; - - if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) { - BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue]; - - if (!enableNativeCrashHandling) { - NSMutableArray *integrations = sentryOptions.integrations.mutableCopy; - [integrations removeObject:@"SentryCrashIntegration"]; - 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) { - BOOL enableAutoPerformanceTracing = - [mutableOptions[@"enableAutoPerformanceTracing"] boolValue]; - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#endif - } - - // Failed requests can only be enabled in one SDK to avoid duplicates - sentryOptions.enableCaptureFailedRequests = NO; - - return sentryOptions; -} - -- (NSString *_Nullable)getURLFromDSN:(NSString *)dsn -{ - NSURL *url = [NSURL URLWithString:dsn]; - if (!url) { - return nil; - } - return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; -} - -- (void)setEventOriginTag:(SentryEvent *)event -{ - if (event.sdk != nil) { - NSString *sdkName = event.sdk[@"name"]; - - // If the event is from react native, it gets set - // there and we do not handle it here. - if ([sdkName isEqual:NATIVE_SDK_NAME]) { - [self setEventEnvironmentTag:event origin:@"ios" environment:@"native"]; - } - } -} - -- (void)setEventEnvironmentTag:(SentryEvent *)event - origin:(NSString *)origin - environment:(NSString *)environment -{ - NSMutableDictionary *newTags = [NSMutableDictionary new]; - - if (nil != event.tags && [event.tags count] > 0) { - [newTags addEntriesFromDictionary:event.tags]; - } - if (nil != origin) { - [newTags setValue:origin forKey:@"event.origin"]; - } - if (nil != environment) { - [newTags setValue:environment forKey:@"event.environment"]; - } - - event.tags = newTags; -} - RCT_EXPORT_METHOD(initNativeReactNavigationNewFrameTracking : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) diff --git a/packages/core/ios/RNSentrySDK.h b/packages/core/ios/RNSentrySDK.h new file mode 100644 index 0000000000..232071d9bc --- /dev/null +++ b/packages/core/ios/RNSentrySDK.h @@ -0,0 +1,31 @@ +#import + +@interface RNSentrySDK : NSObject +SENTRY_NO_INIT + +/** + * @experimental + * Inits and configures Sentry for React Native applications using `sentry.options.json` + * configuration file. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)start; + +/** + * @experimental + * Inits and configures Sentry for React Native applicationsusing `sentry.options.json` + * configuration file and `configureOptions` callback. + * + * The `configureOptions` callback can overwrite the config file options + * and add non-serializable items to the options object. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)startWithConfigureOptions: + (void (^_Nullable)(SentryOptions *_Nonnull options))configureOptions + NS_SWIFT_NAME(start(configureOptions:)); + +@end diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m new file mode 100644 index 0000000000..7d7f4cf9b3 --- /dev/null +++ b/packages/core/ios/RNSentrySDK.m @@ -0,0 +1,71 @@ +#import "RNSentrySDK.h" +#import "RNSentryStart.h" + +static NSString *SENTRY_OPTIONS_RESOURCE_NAME = @"sentry.options"; +static NSString *SENTRY_OPTIONS_RESOURCE_TYPE = @"json"; + +@implementation RNSentrySDK + ++ (void)start +{ + [self startWithConfigureOptions:nil]; +} + ++ (void)startWithConfigureOptions:(void (^)(SentryOptions *options))configureOptions +{ + NSString *path = [[NSBundle mainBundle] pathForResource:SENTRY_OPTIONS_RESOURCE_NAME + ofType:SENTRY_OPTIONS_RESOURCE_TYPE]; + + [self start:path configureOptions:configureOptions]; +} + ++ (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options))configureOptions +{ + NSError *readError = nil; + NSError *parseError = nil; + NSError *optionsError = nil; + + NSData *_Nullable content = nil; + if (path != nil) { + content = [NSData dataWithContentsOfFile:path options:0 error:&readError]; + } + + NSDictionary *dict = nil; + if (content != nil) { + dict = [NSJSONSerialization JSONObjectWithData:content options:0 error:&parseError]; + } + + if (readError != nil) { + NSLog(@"[RNSentry] Failed to load options from %@, with error: %@", path, + readError.localizedDescription); + } + + if (parseError != nil) { + NSLog(@"[RNSentry] Failed to parse JSON from %@, with error: %@", path, + parseError.localizedDescription); + } + + SentryOptions *options = nil; + if (dict != nil) { + options = [RNSentryStart createOptionsWithDictionary:dict error:&optionsError]; + } + + if (optionsError != nil) { + NSLog(@"[RNSentry] Failed to parse options from %@, with error: %@", path, + optionsError.localizedDescription); + } + + if (options == nil) { + // Fallback in case that options file could not be parsed. + options = [[SentryOptions alloc] init]; + } + + [RNSentryStart updateWithReactDefaults:options]; + if (configureOptions != nil) { + configureOptions(options); + } + [RNSentryStart updateWithReactFinals:options]; + [RNSentryStart startWithOptions:options]; +} + +@end diff --git a/packages/core/ios/RNSentryStart.h b/packages/core/ios/RNSentryStart.h new file mode 100644 index 0000000000..01a0617148 --- /dev/null +++ b/packages/core/ios/RNSentryStart.h @@ -0,0 +1,26 @@ +#import +#import + +@interface RNSentryStart : NSObject +SENTRY_NO_INIT + ++ (void)startWithOptions:(NSDictionary *_Nonnull)javascriptOptions + error:(NSError *_Nullable *_Nullable)errorPointer; + ++ (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options + error:(NSError *_Nonnull *_Nonnull)errorPointer; + ++ (void)updateWithReactDefaults:(SentryOptions *)options; ++ (void)updateWithReactFinals:(SentryOptions *)options; + +/** + * @experimental + * Inits and configures Sentry for React Native applications. Make sure to + * set a valid DSN. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)); + +@end diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m new file mode 100644 index 0000000000..84e2d83b02 --- /dev/null +++ b/packages/core/ios/RNSentryStart.m @@ -0,0 +1,222 @@ +#import "RNSentryStart.h" +#import "RNSentryReplay.h" +#import "RNSentryVersion.h" + +#import +#import +#import + +@implementation RNSentryStart + ++ (void)startWithOptions:(NSDictionary *_Nonnull)javascriptOptions + error:(NSError *_Nullable *_Nullable)errorPointer +{ + SentryOptions *options = [self createOptionsWithDictionary:javascriptOptions + error:errorPointer]; + [self updateWithReactDefaults:options]; + [self updateWithReactFinals:options]; + [self startWithOptions:options]; +} + ++ (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)) +{ + NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; + [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; + [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME + version:REACT_NATIVE_SDK_PACKAGE_VERSION]; + + [SentrySDK startWithOptions:options]; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay postInit]; +#endif + + [self postDidBecomeActiveNotification]; +} + ++ (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options + error:(NSError *_Nonnull *_Nonnull)errorPointer +{ + NSMutableDictionary *mutableOptions = [options mutableCopy]; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay updateOptions:mutableOptions]; +#endif + + SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions + didFailWithError:errorPointer]; + if (*errorPointer != nil) { + return nil; + } + + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; + // TODO: For Auto Init from JS dev server is resolved automatically, for init from options file + // dev server has to be specified manually + NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; + sentryOptions.beforeBreadcrumb + = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) + { + NSString *url = breadcrumb.data[@"url"] ?: @""; + + if ([@"http" isEqualToString:breadcrumb.type] + && ((dsn != nil && [url hasPrefix:dsn]) + || (devServerUrl != nil && [url hasPrefix:devServerUrl]))) { + return nil; + } + return breadcrumb; + }; + + // JS options.enableNativeCrashHandling equals to native options.enableCrashHandler + if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) { + BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue]; + + if (!enableNativeCrashHandling) { + NSMutableArray *integrations = sentryOptions.integrations.mutableCopy; + [integrations removeObject:@"SentryCrashIntegration"]; + 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]; + // TODO: For Auto init from JS set automatically for init from options file have to be + // set manually + id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"]; + if (defaultSpotlightUrl != nil) { + sentryOptions.spotlightUrl = defaultSpotlightUrl; + } + } + } + + return sentryOptions; +} + +/** + * This function updates the options with RNSentry defaults. These default can be + * overwritten by users during manual native initialization. + */ ++ (void)updateWithReactDefaults:(SentryOptions *)options +{ + // Failed requests are captured only in JS to avoid duplicates + options.enableCaptureFailedRequests = NO; + + // Tracing is only enabled in JS to avoid duplicate navigation spans + options.tracesSampleRate = nil; + options.tracesSampler = nil; + options.enableTracing = NO; +} + +/** + * This function updates options with changes RNSentry users should not change + * and so this is applied after the configureOptions callback during manual native initialization. + */ ++ (void)updateWithReactFinals:(SentryOptions *)options +{ + SentryBeforeSendEventCallback userBeforeSend = options.beforeSend; + options.beforeSend = ^SentryEvent *(SentryEvent *event) + { + // Unhandled JS Exception are processed by the SDK on JS layer + // To avoid duplicates we drop them in the native SDKs + if (nil != event.exceptions.firstObject.type && + [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location + != NSNotFound) { + return nil; + } + + [self setEventOriginTag:event]; + if (userBeforeSend == nil) { + return event; + } else { + return userBeforeSend(event); + } + }; + + // App Start Hybrid mode doesn't wait for didFinishLaunchNotification and the + // didBecomeVisibleNotification as they will be missed when auto initializing from JS + // App Start measurements are created right after the tracking starts + PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = options.enableAutoPerformanceTracing; +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + // Frames Tracking Hybrid Mode ensures tracking + // is enabled without tracing enabled in the native SDK + PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode + = options.enableAutoPerformanceTracing; +#endif +} + ++ (void)setEventOriginTag:(SentryEvent *)event +{ + if (event.sdk != nil) { + NSString *sdkName = event.sdk[@"name"]; + + // If the event is from react native, it gets set + // there and we do not handle it here. + if ([sdkName isEqual:NATIVE_SDK_NAME]) { + [self setEventEnvironmentTag:event origin:@"ios" environment:@"native"]; + } + } +} + ++ (void)setEventEnvironmentTag:(SentryEvent *)event + origin:(NSString *)origin + environment:(NSString *)environment +{ + NSMutableDictionary *newTags = [NSMutableDictionary new]; + + if (nil != event.tags && [event.tags count] > 0) { + [newTags addEntriesFromDictionary:event.tags]; + } + if (nil != origin) { + [newTags setValue:origin forKey:@"event.origin"]; + } + if (nil != environment) { + [newTags setValue:environment forKey:@"event.environment"]; + } + + event.tags = newTags; +} + ++ (NSString *_Nullable)getURLFromDSN:(NSString *)dsn +{ + NSURL *url = [NSURL URLWithString:dsn]; + if (!url) { + return nil; + } + return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; +} + +static bool sentHybridSdkDidBecomeActive = NO; + ++ (void)postDidBecomeActiveNotification +{ +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + BOOL appIsActive = + [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; +#else + BOOL appIsActive = [[NSApplication sharedApplication] isActive]; +#endif + + // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive + // notification, send it. + if (appIsActive && !sentHybridSdkDidBecomeActive + && (PrivateSentrySDKOnly.options.enableAutoSessionTracking + || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { + // Updates Native App State Manager + // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentryAppStateManager.m#L136 + // Triggers Session Tracker + // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentrySessionTracker.m#L144 + [[NSNotificationCenter defaultCenter] postNotificationName:@"SentryHybridSdkDidBecomeActive" + object:nil]; + + sentHybridSdkDidBecomeActive = true; + } +} + +@end diff --git a/packages/core/ios/RNSentryVersion.m b/packages/core/ios/RNSentryVersion.m index 5bdb2cbbc3..063c8ee257 100644 --- a/packages/core/ios/RNSentryVersion.m +++ b/packages/core/ios/RNSentryVersion.m @@ -3,4 +3,4 @@ NSString *const NATIVE_SDK_NAME = @"sentry.cocoa.react-native"; NSString *const REACT_NATIVE_SDK_NAME = @"sentry.javascript.react-native"; NSString *const REACT_NATIVE_SDK_PACKAGE_NAME = @"npm:@sentry/react-native"; -NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.6.0"; +NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.7.0-alpha.0"; diff --git a/packages/core/jest.config.tools.js b/packages/core/jest.config.tools.js index 5c5902d8a7..996ad05625 100644 --- a/packages/core/jest.config.tools.js +++ b/packages/core/jest.config.tools.js @@ -1,7 +1,7 @@ module.exports = { collectCoverage: true, preset: 'ts-jest', - setupFilesAfterEnv: ['/test/mockConsole.ts'], + setupFilesAfterEnv: ['jest-extended/all', '/test/mockConsole.ts'], globals: { __DEV__: true, }, diff --git a/packages/core/package.json b/packages/core/package.json index 82f939e104..0370e4c183 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@sentry/react-native", "homepage": "https://github.com/getsentry/sentry-react-native", "repository": "https://github.com/getsentry/sentry-react-native", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "description": "Official Sentry SDK for react-native", "typings": "dist/js/index.d.ts", "types": "dist/js/index.d.ts", diff --git a/packages/core/scripts/sentry-xcode.sh b/packages/core/scripts/sentry-xcode.sh index 78970c4c60..336d393220 100755 --- a/packages/core/scripts/sentry-xcode.sh +++ b/packages/core/scripts/sentry-xcode.sh @@ -51,3 +51,22 @@ fi if [ -f "$SENTRY_COLLECT_MODULES" ]; then /bin/sh "$SENTRY_COLLECT_MODULES" fi + +SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX="Skipping options file copy. To disable this behavior, set SENTRY_COPY_OPTIONS_FILE=false in your environment variables." +SENTRY_OPTIONS_FILE_NAME="sentry.options.json" +SENTRY_OPTIONS_FILE_DESTINATION_PATH="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/$SENTRY_OPTIONS_FILE_NAME" +[ -z "$SENTRY_OPTIONS_FILE_PATH" ] && SENTRY_OPTIONS_FILE_PATH="$RN_PROJECT_ROOT/$SENTRY_OPTIONS_FILE_NAME" +[ -z "$SENTRY_COPY_OPTIONS_FILE" ] && SENTRY_COPY_OPTIONS_FILE=true + +if [ "$SENTRY_COPY_OPTIONS_FILE" = true ]; then + if [[ -z "$CONFIGURATION_BUILD_DIR" ]]; then + echo "[Sentry] CONFIGURATION_BUILD_DIR is not set. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + elif [[ -z "$UNLOCALIZED_RESOURCES_FOLDER_PATH" ]]; then + echo "[Sentry] UNLOCALIZED_RESOURCES_FOLDER_PATH is not set. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + elif [ ! -f "$SENTRY_OPTIONS_FILE_PATH" ]; then + echo "[Sentry] $SENTRY_OPTIONS_FILE_PATH not found. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + else + cp "$SENTRY_OPTIONS_FILE_PATH" "$SENTRY_OPTIONS_FILE_DESTINATION_PATH" + echo "[Sentry] Copied $SENTRY_OPTIONS_FILE_PATH to $SENTRY_OPTIONS_FILE_DESTINATION_PATH" + fi +fi diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle index fbbf567412..990703527f 100644 --- a/packages/core/sentry.gradle +++ b/packages/core/sentry.gradle @@ -3,7 +3,7 @@ import org.apache.tools.ant.taskdefs.condition.Os import java.util.regex.Matcher import java.util.regex.Pattern -project.ext.shouldSentryAutoUploadNative = { -> +project.ext.shouldSentryAutoUploadNative = { -> return System.getenv('SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD') != 'true' } @@ -15,9 +15,60 @@ project.ext.shouldSentryAutoUpload = { -> return shouldSentryAutoUploadGeneral() && shouldSentryAutoUploadNative() } +project.ext.shouldCopySentryOptionsFile = { -> // If not set, default to true + return System.getenv('SENTRY_COPY_OPTIONS_FILE') != 'false' +} + def config = project.hasProperty("sentryCli") ? project.sentryCli : []; +def configFile = "sentry.options.json" // Sentry condiguration file +def androidAssetsDir = new File("$rootDir/app/src/main/assets") // Path to Android assets folder + +tasks.register("copySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + def appRoot = project.rootDir.parentFile ?: project.rootDir + def sentryOptionsFile = new File(appRoot, configFile) + if (sentryOptionsFile.exists()) { + if (!androidAssetsDir.exists()) { + androidAssetsDir.mkdirs() + } + + copy { + from sentryOptionsFile + into androidAssetsDir + rename { String fileName -> configFile } + } + logger.lifecycle("Copied ${configFile} to Android assets") + } else { + logger.warn("${configFile} not found in app root (${appRoot})") + } + } +} + +tasks.register("cleanupTemporarySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + def sentryOptionsFile = new File(androidAssetsDir, configFile) + if (sentryOptionsFile.exists()) { + logger.lifecycle("Deleting temporary file: ${sentryOptionsFile.path}") + sentryOptionsFile.delete() + } + } +} + gradle.projectsEvaluated { + // Add a task that copies the sentry.options.json file before the build starts + tasks.named("preBuild").configure { + dependsOn("copySentryJsonConfiguration") + } + // Cleanup sentry.options.json from assets after the build + tasks.matching { task -> + task.name == "build" || task.name.startsWith("assemble") || task.name.startsWith("install") + }.configureEach { + finalizedBy("cleanupTemporarySentryJsonConfiguration") + } + def releases = extractReleasesInfo() if (config.flavorAware && config.sentryProperties) { diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 3c6fdff90c..3ff40508af 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -19,6 +19,7 @@ import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer } from './utils/environment'; import { safeFactory, safeTracesSampler } from './utils/safe'; +import { RN_GLOBAL_OBJ } from './utils/worldwide'; import { NATIVE } from './wrapper'; const DEFAULT_OPTIONS: ReactNativeOptions = { @@ -47,12 +48,17 @@ export function init(passedOptions: ReactNativeOptions): void { return; } - const maxQueueSize = passedOptions.maxQueueSize + const userOptions = { + ...RN_GLOBAL_OBJ.__SENTRY_OPTIONS__, + ...passedOptions, + }; + + const maxQueueSize = userOptions.maxQueueSize // eslint-disable-next-line deprecation/deprecation - ?? passedOptions.transportOptions?.bufferSize + ?? userOptions.transportOptions?.bufferSize ?? DEFAULT_OPTIONS.maxQueueSize; - const enableNative = passedOptions.enableNative === undefined || passedOptions.enableNative + const enableNative = userOptions.enableNative === undefined || userOptions.enableNative ? NATIVE.isNativeAvailable() : false; @@ -75,11 +81,11 @@ export function init(passedOptions: ReactNativeOptions): void { return `${dsnComponents.protocol}://${dsnComponents.host}${port}`; }; - const userBeforeBreadcrumb = safeFactory(passedOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }); + const userBeforeBreadcrumb = safeFactory(userOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }); // Exclude Dev Server and Sentry Dsn request from Breadcrumbs const devServerUrl = getDevServer()?.url; - const dsn = getURLFromDSN(passedOptions.dsn); + const dsn = getURLFromDSN(userOptions.dsn); const defaultBeforeBreadcrumb = (breadcrumb: Breadcrumb, _hint?: BreadcrumbHint): Breadcrumb | null => { const type = breadcrumb.type || ''; const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : ''; @@ -103,26 +109,34 @@ export function init(passedOptions: ReactNativeOptions): void { const options: ReactNativeClientOptions = { ...DEFAULT_OPTIONS, - ...passedOptions, + ...userOptions, enableNative, - enableNativeNagger: shouldEnableNativeNagger(passedOptions.enableNativeNagger), + enableNativeNagger: shouldEnableNativeNagger(userOptions.enableNativeNagger), // If custom transport factory fails the SDK won't initialize - transport: passedOptions.transport + transport: userOptions.transport || makeNativeTransportFactory({ enableNative, }) || makeFetchTransport, transportOptions: { ...DEFAULT_OPTIONS.transportOptions, - ...(passedOptions.transportOptions ?? {}), + ...(userOptions.transportOptions ?? {}), bufferSize: maxQueueSize, }, maxQueueSize, integrations: [], - stackParser: stackParserFromStackParserOptions(passedOptions.stackParser || defaultStackParser), + stackParser: stackParserFromStackParserOptions(userOptions.stackParser || defaultStackParser), beforeBreadcrumb: chainedBeforeBreadcrumb, - initialScope: safeFactory(passedOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }), + initialScope: safeFactory(userOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }), }; + + if (!('autoInitializeNativeSdk' in userOptions) && RN_GLOBAL_OBJ.__SENTRY_OPTIONS__) { + // We expect users to use the file options only in combination with manual native initialization + // eslint-disable-next-line no-console + console.info('Initializing Sentry JS with the options file. Expecting manual native initialization before JS. Native will not be initialized automatically.'); + options.autoInitializeNativeSdk = false; + } + if ('tracesSampler' in options) { options.tracesSampler = safeTracesSampler(options.tracesSampler); } @@ -131,12 +145,12 @@ export function init(passedOptions: ReactNativeOptions): void { options.environment = getDefaultEnvironment(); } - const defaultIntegrations: false | Integration[] = passedOptions.defaultIntegrations === undefined + const defaultIntegrations: false | Integration[] = userOptions.defaultIntegrations === undefined ? getDefaultIntegrations(options) - : passedOptions.defaultIntegrations; + : userOptions.defaultIntegrations; options.integrations = getIntegrationsToSetup({ - integrations: safeFactory(passedOptions.integrations, { loggerMessage: 'The integrations threw an error' }), + integrations: safeFactory(userOptions.integrations, { loggerMessage: 'The integrations threw an error' }), defaultIntegrations, }); initAndBind(ReactNativeClient, options); @@ -145,6 +159,10 @@ export function init(passedOptions: ReactNativeOptions): void { logger.info('Offline caching, native errors features are not available in Expo Go.'); logger.info('Use EAS Build / Native Release Build to test these features.'); } + + if (RN_GLOBAL_OBJ.__SENTRY_OPTIONS__) { + logger.info('Sentry JS initialized with options from the options file.'); + } } /** diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 71c43389a1..e0bd57c178 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -10,6 +10,8 @@ import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; export * from './sentryMetroSerializer'; import { withSentryMiddleware } from './metroMiddleware'; +import { withSentryOptionsFromFile } from './sentryOptionsSerializer'; +import type { MetroCustomSerializer } from './utils'; enableLogger(); @@ -30,6 +32,14 @@ export interface SentryMetroConfigOptions { * @default true */ enableSourceContextInDevelopment?: boolean; + /** + * Load Sentry Options from a file. If `true` it will use the default path. + * If `false` it will not load any options from a file. Only options provided in the code will be used. + * If `string` it will use the provided path. + * + * @default '{projectRoot}/sentry.options.json' + */ + optionsFile?: string | boolean; } export interface SentryExpoConfigOptions { @@ -51,6 +61,7 @@ export function withSentryConfig( annotateReactComponents = false, includeWebReplay = true, enableSourceContextInDevelopment = true, + optionsFile = true, }: SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -68,6 +79,9 @@ export function withSentryConfig( if (enableSourceContextInDevelopment) { newConfig = withSentryMiddleware(newConfig); } + if (optionsFile) { + newConfig = withSentryOptionsFromFile(newConfig, optionsFile); + } return newConfig; } @@ -103,6 +117,10 @@ export function getSentryExpoConfig( newConfig = withSentryMiddleware(newConfig); } + if (options.optionsFile ?? true) { + newConfig = withSentryOptionsFromFile(newConfig, options.optionsFile ?? true); + } + return newConfig; } @@ -155,8 +173,6 @@ export function withSentryBabelTransformer(config: MetroConfig): MetroConfig { }; } -type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; - function withSentryDebugId(config: MetroConfig): MetroConfig { const customSerializer = createSentryMetroSerializer( config.serializer?.customSerializer || undefined, diff --git a/packages/core/src/js/tools/sentryMetroSerializer.ts b/packages/core/src/js/tools/sentryMetroSerializer.ts index fca0979440..feb1e65621 100644 --- a/packages/core/src/js/tools/sentryMetroSerializer.ts +++ b/packages/core/src/js/tools/sentryMetroSerializer.ts @@ -42,6 +42,7 @@ export function unstable_beforeAssetSerializationPlugin({ return [...addDebugIdModule(premodules, debugIdModule)]; } +// TODO: deprecate this and afterwards rename to createSentryDebugIdSerializer /** * Creates a Metro serializer that adds Debug ID module to the plain bundle. * The Debug ID module is a virtual module that provides a debug ID in runtime. diff --git a/packages/core/src/js/tools/sentryOptionsSerializer.ts b/packages/core/src/js/tools/sentryOptionsSerializer.ts new file mode 100644 index 0000000000..f2ab93b383 --- /dev/null +++ b/packages/core/src/js/tools/sentryOptionsSerializer.ts @@ -0,0 +1,104 @@ +import { logger } from '@sentry/core'; +import * as fs from 'fs'; +import type { MetroConfig, Module } from 'metro'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as countLines from 'metro/src/lib/countLines'; +import * as path from 'path'; + +import type { MetroCustomSerializer, VirtualJSOutput } from './utils'; +import { createSet } from './utils'; + +const DEFAULT_OPTIONS_FILE_NAME = 'sentry.options.json'; + +/** + * Loads Sentry options from a file in + */ +export function withSentryOptionsFromFile(config: MetroConfig, optionsFile: string | boolean): MetroConfig { + if (optionsFile === false) { + return config; + } + + const { projectRoot } = config; + if (!projectRoot) { + // eslint-disable-next-line no-console + console.error('[@sentry/react-native/metro] Project root is required to load Sentry options from a file'); + return config; + } + + let optionsPath = path.join(projectRoot, DEFAULT_OPTIONS_FILE_NAME); + if (typeof optionsFile === 'string' && path.isAbsolute(optionsFile)) { + optionsPath = optionsFile; + } else if (typeof optionsFile === 'string') { + optionsPath = path.join(projectRoot, optionsFile); + } + + const originalSerializer = config.serializer?.customSerializer; + if (!originalSerializer) { + // It's okay to bail here because we don't expose this for direct usage, but as part of `withSentryConfig` + // If used directly in RN, the user is responsible for providing a custom serializer first, Expo provides serializer in default config + // eslint-disable-next-line no-console + console.error( + '[@sentry/react-native/metro] `config.serializer.customSerializer` is required to load Sentry options from a file', + ); + return config; + } + + const sentryOptionsSerializer: MetroCustomSerializer = (entryPoint, preModules, graph, options) => { + const sentryOptionsModule = createSentryOptionsModule(optionsPath); + if (sentryOptionsModule) { + (preModules as Module[]).push(sentryOptionsModule); + } + return originalSerializer(entryPoint, preModules, graph, options); + }; + + return { + ...config, + serializer: { + ...config.serializer, + customSerializer: sentryOptionsSerializer, + }, + }; +} + +function createSentryOptionsModule(filePath: string): Module | null { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.debug(`[@sentry/react-native/metro] Sentry options file does not exist at ${filePath}`); + } else { + logger.error(`[@sentry/react-native/metro] Failed to read Sentry options file at ${filePath}`); + } + return null; + } + + let parsedContent: Record; + try { + parsedContent = JSON.parse(content); + } catch (error) { + logger.error(`[@sentry/react-native/metro] Failed to parse Sentry options file at ${filePath}`); + return null; + } + + const minifiedContent = JSON.stringify(parsedContent); + const optionsCode = `var __SENTRY_OPTIONS__=${minifiedContent};`; + + logger.debug(`[@sentry/react-native/metro] Sentry options added to the bundle from file at ${filePath}`); + return { + dependencies: new Map(), + getSource: () => Buffer.from(optionsCode), + inverseDependencies: createSet(), + path: '__sentry-options__', + output: [ + { + type: 'js/script/virtual', + data: { + code: optionsCode, + lineCount: countLines(optionsCode), + map: [], + }, + }, + ], + }; +} diff --git a/packages/core/src/js/tools/utils.ts b/packages/core/src/js/tools/utils.ts index 769dc9abd4..82ff4075e2 100644 --- a/packages/core/src/js/tools/utils.ts +++ b/packages/core/src/js/tools/utils.ts @@ -1,9 +1,11 @@ import * as crypto from 'crypto'; // eslint-disable-next-line import/no-extraneous-dependencies -import type { Module, ReadOnlyGraph, SerializerOptions } from 'metro'; +import type { MetroConfig, Module, ReadOnlyGraph, SerializerOptions } from 'metro'; // eslint-disable-next-line import/no-extraneous-dependencies import type CountingSet from 'metro/src/lib/CountingSet'; +export type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; + // Variant of MixedOutput // https://github.com/facebook/metro/blob/9b85f83c9cc837d8cd897aa7723be7da5b296067/packages/metro/src/DeltaBundler/types.flow.js#L21 export type VirtualJSOutput = { diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index c1a4ae5dbb..0dc265763d 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -2,6 +2,7 @@ import type { InternalGlobal } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import type { ErrorUtils } from 'react-native/types'; +import type { ReactNativeOptions } from '../options'; import type { ExpoGlobalObject } from './expoglobalobject'; /** Internal Global object interface with common and Sentry specific properties */ @@ -25,6 +26,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { __BUNDLE_START_TIME__?: number; nativePerformanceNow?: () => number; TextEncoder?: TextEncoder; + __SENTRY_OPTIONS__?: ReactNativeOptions; } type TextEncoder = { diff --git a/packages/core/src/js/version.ts b/packages/core/src/js/version.ts index 6718af2331..9714e3938e 100644 --- a/packages/core/src/js/version.ts +++ b/packages/core/src/js/version.ts @@ -1,3 +1,3 @@ export const SDK_PACKAGE_NAME = 'npm:@sentry/react-native'; export const SDK_NAME = 'sentry.javascript.react-native'; -export const SDK_VERSION = '6.6.0'; +export const SDK_VERSION = '6.7.0-alpha.0'; diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index afd6137c8a..0e64264899 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -1,13 +1,15 @@ -import type { BaseTransportOptions, Breadcrumb, BreadcrumbHint, ClientOptions, Integration, Scope } from '@sentry/core'; +import type { Breadcrumb, BreadcrumbHint, Integration, Scope } from '@sentry/core'; import { initAndBind, logger } from '@sentry/core'; import { makeFetchTransport } from '@sentry/react'; import { getDevServer } from '../src/js/integrations/debugsymbolicatorutils'; +import type { ReactNativeClientOptions } from '../src/js/options'; import { init, withScope } from '../src/js/sdk'; import type { ReactNativeTracingIntegration } from '../src/js/tracing'; import { REACT_NATIVE_TRACING_INTEGRATION_NAME, reactNativeTracingIntegration } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; +import { RN_GLOBAL_OBJ } from '../src/js/utils/worldwide'; import { NATIVE } from './mockWrapper'; import { firstArg, secondArg } from './testutils'; @@ -109,6 +111,60 @@ describe('Tests the SDK functionality', () => { }); }); + describe('initialization from sentry.options.json', () => { + it('initializes without __SENTRY_OPTIONS__', () => { + delete RN_GLOBAL_OBJ.__SENTRY_OPTIONS__; + init({}); + expect(initAndBind).toHaveBeenCalledOnce(); + }); + + it('adds options from __SENTRY_OPTIONS__', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = { + dsn: 'https://key@example.io/value', + }; + init({}); + expect(usedOptions()?.dsn).toBe('https://key@example.io/value'); + }); + + it('options init override options from __SENTRY_OPTIONS__', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = { + dsn: 'https://key@example.io/file', + }; + init({ + dsn: 'https://key@example.io/code', + }); + expect(usedOptions()?.dsn).toBe('https://key@example.io/code'); + }); + + it('initializing with __SENTRY_OPTIONS__ disabled native auto initialization', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({}); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(false); + }); + + it('initializing without __SENTRY_OPTIONS__ enables native auto initialization', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = undefined; + init({}); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(true); + }); + + it('initializing with __SENTRY_OPTIONS__ keeps native auto initialization true if set', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({ + autoInitializeNativeSdk: true, + }); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(true); + }); + + it('initializing with __SENTRY_OPTIONS__ keeps native auto initialization false if set', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({ + autoInitializeNativeSdk: false, + }); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(false); + }); + }); + describe('environment', () => { it('detect development environment', () => { (getDefaultEnvironment as jest.Mock).mockImplementation(() => 'development'); @@ -173,7 +229,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({}); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -182,7 +237,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({ enableNative: true }); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -191,7 +245,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({ enableNative: false }); expect(NATIVE.isNativeAvailable).not.toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -204,7 +257,6 @@ describe('Tests the SDK functionality', () => { }); expect(usedOptions()?.transport).toEqual(mockTransport); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); }); }); @@ -1058,7 +1110,7 @@ function createMockedIntegration({ name }: { name?: string } = {}): Integration }; } -function usedOptions(): ClientOptions | undefined { +function usedOptions(): ReactNativeClientOptions | undefined { return (initAndBind as jest.MockedFunction).mock.calls[0]?.[secondArg]; } diff --git a/packages/core/test/tools/sentryOptionsSerializer.test.ts b/packages/core/test/tools/sentryOptionsSerializer.test.ts new file mode 100644 index 0000000000..ed946d098a --- /dev/null +++ b/packages/core/test/tools/sentryOptionsSerializer.test.ts @@ -0,0 +1,209 @@ +import { logger } from '@sentry/core'; +import * as fs from 'fs'; +import type { Graph, Module, SerializerOptions } from 'metro'; + +import { withSentryOptionsFromFile } from '../../src/js/tools/sentryOptionsSerializer'; +import { createSet } from '../../src/js/tools/utils'; + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + +const consoleErrorSpy = jest.spyOn(console, 'error'); +const loggerDebugSpy = jest.spyOn(logger, 'debug'); +const loggerErrorSpy = jest.spyOn(logger, 'error'); + +const customSerializerMock = jest.fn(); +let mockedPreModules: Module[] = []; + +describe('Sentry Options Serializer', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedPreModules = createMockedPreModules(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('returns original config if optionsFile is false', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const result = withSentryOptionsFromFile(config(), false); + expect(result).toEqual(config()); + }); + + test('logs error and returns original config if projectRoot is missing', () => { + const config = () => ({ + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const result = withSentryOptionsFromFile(config(), true); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Project root is required')); + expect(result).toEqual(config()); + }); + + test('logs error and returns original config if customSerializer is missing', () => { + const config = () => ({ + projectRoot: '/test', + serializer: {}, + }); + const consoleErrorSpy = jest.spyOn(console, 'error'); + + const result = withSentryOptionsFromFile(config(), true); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('`config.serializer.customSerializer` is required'), + ); + expect(result).toEqual(config()); + }); + + test('adds sentry options module when file exists and is valid JSON', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const mockOptions = { test: 'value' }; + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockOptions)); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(mockedPreModules).toHaveLength(2); + expect(mockedPreModules.at(-1)).toEqual( + expect.objectContaining({ + getSource: expect.any(Function), + path: '__sentry-options__', + output: [ + { + type: 'js/script/virtual', + data: { + code: 'var __SENTRY_OPTIONS__={"test":"value"};', + lineCount: 1, + map: [], + }, + }, + ], + }), + ); + expect(mockedPreModules.at(-1).getSource().toString()).toEqual(mockedPreModules.at(-1).output[0].data.code); + expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('options added to the bundle')); + }); + + test('logs error and does not add module when file does not exist', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw { code: 'ENOENT' }; + }); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('options file does not exist')); + expect(mockedPreModules).toMatchObject(createMockedPreModules()); + }); + + test('logs error and does not add module when file contains invalid JSON', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + (fs.readFileSync as jest.Mock).mockReturnValue('invalid json'); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse Sentry options file')); + expect(mockedPreModules).toMatchObject(createMockedPreModules()); + }); + + test('calls original serializer with correct arguments and returns its result', () => { + const mockedEntryPoint = 'entryPoint'; + const mockedGraph: Graph = jest.fn() as unknown as Graph; + const mockedOptions: SerializerOptions = jest.fn() as unknown as SerializerOptions; + const mockedResult = {}; + const originalSerializer = jest.fn().mockReturnValue(mockedResult); + + const actualConfig = withSentryOptionsFromFile( + { + projectRoot: '/test', + serializer: { + customSerializer: originalSerializer, + }, + }, + true, + ); + const actualResult = actualConfig.serializer?.customSerializer( + mockedEntryPoint, + mockedPreModules, + mockedGraph, + mockedOptions, + ); + + expect(originalSerializer).toHaveBeenCalledWith(mockedEntryPoint, mockedPreModules, mockedGraph, mockedOptions); + expect(actualResult).toEqual(mockedResult); + }); + + test('uses custom file path when optionsFile is a string', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + withSentryOptionsFromFile(config(), 'custom/path.json').serializer?.customSerializer( + null, + mockedPreModules, + null, + null, + ); + withSentryOptionsFromFile(config(), '/absolute/path.json').serializer?.customSerializer( + null, + mockedPreModules, + null, + null, + ); + + expect(fs.readFileSync).toHaveBeenCalledWith('/test/custom/path.json', expect.anything()); + expect(fs.readFileSync).toHaveBeenCalledWith('/absolute/path.json', expect.anything()); + }); +}); + +function createMockedPreModules(): Module[] { + return [createMinimalModule()]; +} + +function createMinimalModule(): Module { + return { + dependencies: new Map(), + getSource: getEmptySource, + inverseDependencies: createSet(), + path: '__sentry-options__', + output: [], + }; +} + +function getEmptySource(): Buffer { + return Buffer.from(''); +} diff --git a/performance-tests/TestAppPlain/package.json b/performance-tests/TestAppPlain/package.json index 67b6a8b3dd..74a009292d 100644 --- a/performance-tests/TestAppPlain/package.json +++ b/performance-tests/TestAppPlain/package.json @@ -1,6 +1,6 @@ { "name": "TestAppPlain", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "android": "react-native run-android", diff --git a/performance-tests/TestAppSentry/package.json b/performance-tests/TestAppSentry/package.json index de301f4bf2..7e8f774c2a 100644 --- a/performance-tests/TestAppSentry/package.json +++ b/performance-tests/TestAppSentry/package.json @@ -1,6 +1,6 @@ { "name": "TestAppSentry", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "android": "react-native run-android", @@ -8,7 +8,7 @@ "start": "react-native start" }, "dependencies": { - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "react": "18.1.0", "react-native": "0.70.15" }, diff --git a/samples/expo/app.json b/samples/expo/app.json index c97346d0b5..a9251abe0a 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -4,7 +4,7 @@ "slug": "sentry-react-native-expo-sample", "jsEngine": "hermes", "scheme": "sentry-expo-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -19,7 +19,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", - "buildNumber": "36" + "buildNumber": "37" }, "android": { "adaptiveIcon": { @@ -27,7 +27,7 @@ "backgroundColor": "#ffffff" }, "package": "io.sentry.expo.sample", - "versionCode": 36 + "versionCode": 37 }, "web": { "bundler": "metro", diff --git a/samples/expo/package.json b/samples/expo/package.json index b151813e59..bf2fcbc1d5 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-expo-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -16,7 +16,7 @@ "set-version": "npx react-native-version --skip-tag --never-amend" }, "dependencies": { - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "@types/react": "~18.3.12", "expo": "^52.0.0", "expo-constants": "~17.0.3", diff --git a/samples/expo/sentry.options.json b/samples/expo/sentry.options.json new file mode 100644 index 0000000000..53ae525bc0 --- /dev/null +++ b/samples/expo/sentry.options.json @@ -0,0 +1,18 @@ +{ + "dsn": "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561", + "debug": true, + "environment": "dev", + "enableUserInteractionTracing": true, + "enableAutoSessionTracking": true, + "sessionTrackingIntervalMillis": 30000, + "enableTracing": true, + "tracesSampleRate": 1.0, + "attachStacktrace": true, + "attachScreenshot": true, + "attachViewHierarchy": true, + "enableCaptureFailedRequests": true, + "profilesSampleRate": 1.0, + "replaysSessionSampleRate": 1.0, + "replaysOnErrorSampleRate": 1.0, + "spotlight": true +} diff --git a/samples/react-native-macos/package.json b/samples/react-native-macos/package.json index 47d5e98ced..2fa48cf7cc 100644 --- a/samples/react-native-macos/package.json +++ b/samples/react-native-macos/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-macos-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "start": "react-native start --experimental-debugger", @@ -18,7 +18,7 @@ "@react-navigation/stack": "^6.3.20", "@sentry/core": "8.54.0", "@sentry/react": "8.54.0", - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "delay": "^6.0.0", "react": "18.2.0", "react-native": "0.73.9", diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index 4c1341b4c8..2d24b2ede1 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -136,8 +136,8 @@ android { applicationId "io.sentry.reactnative.sample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 38 - versionName "6.6.0" + versionCode 39 + versionName "6.7.0-alpha.0" } signingConfigs { diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt index 07747f085c..6546ca8b10 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt @@ -11,10 +11,7 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.soloader.SoLoader -import io.sentry.Hint -import io.sentry.SentryEvent -import io.sentry.SentryOptions.BeforeSendCallback -import io.sentry.android.core.SentryAndroid +import io.sentry.react.RNSentrySDK class MainApplication : Application(), @@ -51,28 +48,12 @@ class MainApplication : } private fun initializeSentry() { - SentryAndroid.init(this) { options -> - // Only options set here will apply to the Android SDK - // Options from JS are not passed to the Android SDK when initialized manually + RNSentrySDK.init(this) { options -> + // Options set here will apply to the Android SDK overriding the ones from `sentry.options.json` options.dsn = "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561" options.isDebug = true - - options.beforeSend = - BeforeSendCallback { event: SentryEvent, hint: 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 { - val ex = event.exceptions!![0] - if (null != ex && ex.type!!.contains("JavascriptException")) { - return@BeforeSendCallback null - } - } catch (ignored: Throwable) { - // We do nothing - } - - event - } } + + // RNSentrySDK.init(this) } } diff --git a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm index 71a62884ac..fe0893c5b6 100644 --- a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm +++ b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm @@ -9,6 +9,7 @@ # import #endif +#import #import #import @@ -19,44 +20,12 @@ @implementation AppDelegate -- (void)initializeSentry -{ - [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { - // Only options set here will apply to the iOS SDK - // Options from JS are not passed to the iOS SDK when initialized manually - options.dsn = @"https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561"; - options.debug = YES; // Enabled debug when first installing is always helpful - - options.beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception - // of react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - NSLog(@"Unhandled JS Exception"); - return nil; - } - - return event; - }; - - // Enable the App start and Frames tracking measurements - // If this is disabled the app start and frames tracking - // won't be passed from native to JS transactions - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = true; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = true; -#endif - }]; -} - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // When the native init is enabled the `autoInitializeNativeSdk` // in JS has to be set to `false` - // [self initializeSentry]; + // [RNSentrySDK start]; self.moduleName = @"sentry-react-native-sample"; // You can add your custom initial props in the dictionary below. diff --git a/samples/react-native/ios/sentryreactnativesample/Info.plist b/samples/react-native/ios/sentryreactnativesample/Info.plist index 12ed645a84..17529727b2 100644 --- a/samples/react-native/ios/sentryreactnativesample/Info.plist +++ b/samples/react-native/ios/sentryreactnativesample/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 6.6.0 + 6.7.0 CFBundleSignature ???? CFBundleVersion - 43 + 44 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist index 515e2c9f2a..363ef122d5 100644 --- a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist +++ b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 6.6.0 + 6.7.0 CFBundleSignature ???? CFBundleVersion - 43 + 44 diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 02d2f942dd..143a4b6c7f 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "postinstall": "patch-package", @@ -25,7 +25,7 @@ "@react-navigation/native": "^7.0.3", "@react-navigation/native-stack": "^7.0.3", "@react-navigation/stack": "^7.0.3", - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "delay": "^6.0.0", "react": "18.3.1", "react-native": "0.76.3", diff --git a/samples/react-native/sentry.options.json b/samples/react-native/sentry.options.json new file mode 100644 index 0000000000..f6465b7923 --- /dev/null +++ b/samples/react-native/sentry.options.json @@ -0,0 +1,20 @@ +{ + "dsn": "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561", + "debug": true, + "environment": "dev", + "enableUserInteractionTracing": true, + "enableAutoSessionTracking": true, + "sessionTrackingIntervalMillis": 30000, + "enableTracing": true, + "tracesSampleRate": 1.0, + "attachStacktrace": true, + "attachScreenshot": true, + "attachViewHierarchy": true, + "enableCaptureFailedRequests": true, + "_release": "myapp@1.2.3+1", + "_dist": 1, + "profilesSampleRate": 1.0, + "replaysSessionSampleRate": 1.0, + "replaysOnErrorSampleRate": 1.0, + "spotlight": true +} diff --git a/yarn.lock b/yarn.lock index 6e3e8b6a99..64b67a8bb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7805,7 +7805,7 @@ __metadata: languageName: node linkType: hard -"@sentry/react-native@6.6.0, @sentry/react-native@workspace:packages/core": +"@sentry/react-native@6.7.0-alpha.0, @sentry/react-native@workspace:packages/core": version: 0.0.0-use.local resolution: "@sentry/react-native@workspace:packages/core" dependencies: @@ -9480,7 +9480,7 @@ __metadata: dependencies: "@babel/core": ^7.12.9 "@babel/runtime": ^7.12.5 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 metro-react-native-babel-preset: ^0.72.3 react: 18.1.0 react-native: 0.70.15 @@ -24156,7 +24156,7 @@ __metadata: "@babel/preset-env": ^7.25.3 "@babel/preset-typescript": ^7.18.6 "@sentry/core": 8.54.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/node": ^20.9.3 "@types/react": ^18.2.64 appium: 2.4.1 @@ -24185,7 +24185,7 @@ __metadata: "@babel/core": ^7.26.0 "@babel/preset-env": ^7.26.0 "@sentry/babel-plugin-component-annotate": ^2.18.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/node": 20.10.4 "@types/react": ~18.3.12 expo: ^52.0.0 @@ -24222,7 +24222,7 @@ __metadata: "@react-navigation/stack": ^6.3.20 "@sentry/core": 8.54.0 "@sentry/react": 8.54.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0 @@ -24268,7 +24268,7 @@ __metadata: "@react-navigation/native-stack": ^7.0.3 "@react-navigation/stack": ^7.0.3 "@sentry/babel-plugin-component-annotate": ^2.18.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0