diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f1945dbed..540362c035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,215 @@ > [!IMPORTANT] -> If you are upgrading to the `6.x` versions of the Sentry React Native SDK from `5.x` or below, +> If you are upgrading to the `7.x` versions of the Sentry React Native SDK from `6.x` or below, > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +## Features + +- Logs now contains more attributes like release, os and device information ([#5032](https://github.com/getsentry/sentry-react-native/pull/5032)) + +### Dependencies + +- Bump Android SDK from v8.17.0 to v8.19.0 ([#5034](https://github.com/getsentry/sentry-react-native/pull/5034), [#5063](https://github.com/getsentry/sentry-react-native/pull/5063)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8190) + - [diff](https://github.com/getsentry/sentry-java/compare/8.17.0...8.19.0) + +## 7.0.0-rc.1 + +### Various fixes & improvements + +- fix(sdk): Set `{{auto}}` if `user.ip_address` is `undefined` and `sendDefaultPii: true` (#4466) by @krystofwoldrich +- fix(appStart): Align span description with other platforms (#4636) by @krystofwoldrich + +## 7.0.0-beta.2 + +### Features + +- Automatically detect Release name and version for Expo Web ([#4967](https://github.com/getsentry/sentry-react-native/pull/4967)) + +### Changes + +- Expose `featureFlagsIntegration` ([#4984](https://github.com/getsentry/sentry-react-native/pull/4984)) + +### Breaking changes + +- Tags formatting logic updated ([#4965](https://github.com/getsentry/sentry-react-native/pull/4965)) +Here are the altered/unaltered types, make sure to update your UI filters and alerts. + + Unaltered: string, null, number, and undefined values remain unchanged. + + Altered: Boolean values are now capitalized: true -> True, false -> False. + +### Fixes + +- tags with symbol are now logged ([#4965](https://github.com/getsentry/sentry-react-native/pull/4965)) +- ignoreError now filters Native errors ([#4948](https://github.com/getsentry/sentry-react-native/pull/4948)) + +You can use strings to filter errors or RegEx for filtering with a pattern. + +example: + +```typescript + ignoreErrors: [ + '1234', // Will filter any error message that contains 1234. + '.*1234', // Will not filter as regex, instead will filter messages that contains '.*1234" + /.*1234/, // Regex will filter any error message that ends with 1234 + /.*1234.*/ // Regex will filter any error message that contains 1234. + ] +``` + +### Dependencies + +- Bump Android SDK from v8.14.0 to v8.17.0 ([#4953](https://github.com/getsentry/sentry-react-native/pull/4953), [#4955](https://github.com/getsentry/sentry-react-native/pull/4955), [#4987](https://github.com/getsentry/sentry-react-native/pull/4987)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8170) + - [diff](https://github.com/getsentry/sentry-java/compare/8.14.0...8.17.0) + +## 7.0.0-beta.1 + +### Upgrading from 6.x to 7.0 + +Version 7 of the Sentry React Native SDK primarily introduces API cleanup and version support changes based on the Sentry Javascript SDK version 9. This update contains behavioral changes that will not be caught by type checkers, linters, or tests, so we recommend carefully reading through the entire migration guide instead of relying on automatic tooling. + +Version 7 of the SDK is compatible with Sentry self-hosted versions 24.4.2 or higher (unchanged from v6). Lower versions may continue to work, but may not support all features. + +### Major Changes + +- Set `{{auto}}` if `user.ip_address` is `undefined` and `sendDefaultPii: true` ([#4466](https://github.com/getsentry/sentry-react-native/pull/4466)) +- `Sentry.captureUserFeedback` removed, use `Sentry.captureFeedback` instead ([#4855](https://github.com/getsentry/sentry-react-native/pull/4855)) + +### Major Changes from Sentry JS SDK v9 + +- Exceptions from `captureConsoleIntegration` are now marked as handled: true by default +- `shutdownTimeout` moved from `core` to `@sentry/react-native` +- `hasTracingEnabled` was renamed to `hasSpansEnabled` +- You can no longer drop spans or return null on `beforeSendSpan` hook +- Fork `scope` if custom scope is passed to `startSpanManual` or `startSpan` + +#### Removed types + +- TransactionNamingScheme +- Request +- Scope (prefer using the Scope class) + +#### Other removed items. + +- `autoSessionTracking` from options. + To enable session tracking, ensure that `enableAutoSessionTracking` is enabled. +- `enableTracing`. Instead, set `tracesSampleRate` to a value greater than `zero` to `enable tracing`, `0` to keep tracing integrations active without sampling, or `undefined` to disable the performance integration. +- `getCurrentHub()`, `Hub`, and `getCurrentHubShim()` +- `spanId` from propagation `context` +- metrics API +- `transactionContext` from `samplingContext` +- `@sentry/utils` package, the exports were moved to `@sentry/core` +- Standalone `Client` interface & deprecate `BaseClient` + +### Features + +- Add experimental support for Log tracing ([#4827](https://github.com/getsentry/sentry-react-native/pull/4827)) + +To enable it add the following code to your Sentry Options: + +```typescript +Sentry.init({ + // other options... + _experiments: { + enableLogs: true, + }, +}); +``` + +You can also filter the logs being collected by adding beforeSendLogs into `_experiments` + +```typescript +Sentry.init({ + // other options... + _experiments: { + enableLogs: true, + beforeSendLog: (log) => { + return log; + }, + } +}); +``` + +### Changes + +- Expose `logger` and `consoleLoggingIntegration` ([#4930](https://github.com/getsentry/sentry-react-native/pull/4930)) +- Remove deprecated `appOwnership` constant use in Expo Go detection ([#4893](https://github.com/getsentry/sentry-react-native/pull/4893)) +- Disable AppStart and NativeFrames in unsupported environments (web, Expo Go) ([#4897](https://github.com/getsentry/sentry-react-native/pull/4897)) + +### Self Hosted + +- It is recommended to use Sentry Self Hosted version `25.2.0` or new for React Native V7 or newer + +### Dependencies + +- Bump Android SDK from v8.13.2 to v8.14.0 ([#4929](https://github.com/getsentry/sentry-react-native/pull/4929), [#4934](https://github.com/getsentry/sentry-react-native/pull/4934)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8140) + - [diff](https://github.com/getsentry/sentry-java/compare/8.13.2...8.14.0) + +## 7.0.0-beta.0 + +### Upgrading from 6.x to 7.0 + +Version 7 of the Sentry React Native SDK primarily introduces API cleanup and version support changes based on the Sentry Javascript SDK version 9. This update contains behavioral changes that will not be caught by type checkers, linters, or tests, so we recommend carefully reading through the entire migration guide instead of relying on automatic tooling. + +Version 7 of the SDK is compatible with Sentry self-hosted versions 24.4.2 or higher (unchanged from v6). Lower versions may continue to work, but may not support all features. + +### Major Changes + +- Set `{{auto}}` if `user.ip_address` is `undefined` and `sendDefaultPii: true` ([#4466](https://github.com/getsentry/sentry-react-native/pull/4466)) +- `Sentry.captureUserFeedback` removed, use `Sentry.captureFeedback` instead ([#4855](https://github.com/getsentry/sentry-react-native/pull/4855)) + +### Major Changes from Sentry JS SDK v9 + +- Exceptions from `captureConsoleIntegration` are now marked as handled: true by default +- `shutdownTimeout` moved from `core` to `@sentry/react-native` +- `hasTracingEnabled` was renamed to `hasSpansEnabled` +- You can no longer drop spans or return null on `beforeSendSpan` hook +- Fork `scope` if custom scope is passed to `startSpanManual` or `startSpan` + +#### Removed types + +- TransactionNamingScheme +- Request +- Scope (prefer using the Scope class) + +#### Other removed items. + +- `autoSessionTracking` from options. + To enable session tracking, ensure that `enableAutoSessionTracking` is enabled. +- `enableTracing`. Instead, set `tracesSampleRate` to a value greater than `zero` to `enable tracing`, `0` to keep tracing integrations active without sampling, or `undefined` to disable the performance integration. +- `getCurrentHub()`, `Hub`, and `getCurrentHubShim()` +- `spanId` from propagation `context` +- metrics API +- `transactionContext` from `samplingContext` +- `@sentry/utils` package, the exports were moved to `@sentry/core` +- Standalone `Client` interface & deprecate `BaseClient` + +### Changes + +- Use `Replay` interface for `browserReplayIntegration` return type ([#4858](https://github.com/getsentry/sentry-react-native/pull/4858)) +- Allow using `browserReplayIntegration` without `isWeb` guard ([#4858](https://github.com/getsentry/sentry-react-native/pull/4858)) + - The integration returns noop in non-browser environments +- Use single `encodeUTF8` implementation through the SDK ([#4885](https://github.com/getsentry/sentry-react-native/pull/4885)) +- Use global `TextEncoder` (available with Hermes in React Native 0.74 or higher) to improve envelope encoding performance. ([#4874](https://github.com/getsentry/sentry-react-native/pull/4874)) +- `breadcrumbsIntegration` disables React Native incompatible options automatically ([#4886](https://github.com/getsentry/sentry-react-native/pull/4886)) +- On React Native Web, `browserSessionIntegration` is added when `enableAutoSessionTracking` is set to `True` ([#4732](https://github.com/getsentry/sentry-react-native/pull/4732)) +- Change `Cold/Warm App Start` span description to `Cold/Warm Start` ([#4636](https://github.com/getsentry/sentry-react-native/pull/4636)) + +### Dependencies + +- Bump JavaScript SDK from v8.54.0 to v9.22.0 ([#4568](https://github.com/getsentry/sentry-react-native/pull/4568), [#4752](https://github.com/getsentry/sentry-react-native/pull/4752), [#4860](https://github.com/getsentry/sentry-react-native/pull/4860)) + - [changelog](https://github.com/getsentry/sentry-javascript/blob/9.22.0/CHANGELOG.md) + - [diff](https://github.com/getsentry/sentry-javascript/compare/8.54.0...9.22.0) +- Bump Android SDK from v7.20.1 to v8.13.2 ([#4490](https://github.com/getsentry/sentry-react-native/pull/4490), [#4847](https://github.com/getsentry/sentry-react-native/pull/4847)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8132) + - [diff](https://github.com/getsentry/sentry-java/compare/7.20.1...8.13.2) + ## 6.19.0 ### Fixes @@ -194,6 +399,71 @@ - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2460) - [diff](https://github.com/getsentry/sentry-cli/compare/2.45.0...2.46.0) +## 7.0.0-alpha.0 + +### Upgrading from 6.x to 7.0 + +Version 7 of the Sentry React Native SDK primarily introduces API cleanup and version support changes based on the Sentry Javascript SDK version 9. This update contains behavioral changes that will not be caught by type checkers, linters, or tests, so we recommend carefully reading through the entire migration guide instead of relying on automatic tooling. + +Version 7 of the SDK is compatible with Sentry self-hosted versions 24.4.2 or higher (unchanged from v6). Lower versions may continue to work, but may not support all features. + +### Fixes + +- Expo Updates Context is passed to native after native init to be available for crashes ([#4808](https://github.com/getsentry/sentry-react-native/pull/4808)) +- Expo Updates Context values should all be lowercase ([#4809](https://github.com/getsentry/sentry-react-native/pull/4809)) +- Avoid duplicate network requests (fetch, xhr) by default ([#4816](https://github.com/getsentry/sentry-react-native/pull/4816)) + - `traceFetch` is disabled by default on mobile as RN uses a polyfill which will be traced by `traceXHR` + +### Major Changes + +- Set `{{auto}}` if `user.ip_address` is `undefined` and `sendDefaultPii: true` ([#4466](https://github.com/getsentry/sentry-react-native/pull/4466)) +- Exceptions from `captureConsoleIntegration` are now marked as handled: true by default +- `shutdownTimeout` moved from `core` to `@sentry/react-native` +- `hasTracingEnabled` was renamed to `hasSpansEnabled` +- You can no longer drop spans or return null on `beforeSendSpan` hook + +### Removed types + +- TransactionNamingScheme +- Request +- Scope (prefer using the Scope class) + +### Other removed items. + +- `autoSessionTracking` from options. + To enable session tracking, ensure that `enableAutoSessionTracking` is enabled. +- `enableTracing`. Instead, set `tracesSampleRate` to a value greater than `zero` to `enable tracing`, `0` to keep tracing integrations active without sampling, or `undefined` to disable the performance integration. +- `getCurrentHub()`, `Hub`, and `getCurrentHubShim()` +- `spanId` from propagation `context` +- metrics API +- `transactionContext` from `samplingContext` +- `@sentry/utils` package, the exports were moved to `@sentry/core` +- Standalone `Client` interface & deprecate `BaseClient` + +### Other Changes + +- Fork `scope` if custom scope is passed to `startSpanManual` or `startSpan` +- On React Native Web, `browserSessionIntegration` is added when `enableAutoSessionTracking` is set to `True` ([#4732](https://github.com/getsentry/sentry-react-native/pull/4732)) +- Change `Cold/Warm App Start` span description to `Cold/Warm Start` ([#4636](https://github.com/getsentry/sentry-react-native/pull/4636)) + +### Dependencies + +- Bump JavaScript SDK from v8.54.0 to v9.12.0 ([#4568](https://github.com/getsentry/sentry-react-native/pull/4568), [#4752](https://github.com/getsentry/sentry-react-native/pull/4752)) + - [changelog](https://github.com/getsentry/sentry-javascript/blob/9.12.0/CHANGELOG.md) + - [diff](https://github.com/getsentry/sentry-javascript/compare/8.54.0...9.12.0) +- Bump Android SDK from v7.20.1 to v8.11.1 ([#4490](https://github.com/getsentry/sentry-react-native/pull/4490)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#8111) + - [diff](https://github.com/getsentry/sentry-java/compare/7.20.1...8.11.1) +- Bump CLI from v2.43.1 to v2.45.0 ([#4804](https://github.com/getsentry/sentry-react-native/pull/4804), [#4818](https://github.com/getsentry/sentry-react-native/pull/4818)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2450) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.43.1...2.45.0) +- Bump Bundler Plugins from v3.3.1 to v3.4.0 ([#4805](https://github.com/getsentry/sentry-react-native/pull/4805)) + - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#340) + - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.3.1...3.4.0) +- Bump Cocoa SDK from v8.49.2 to v8.50.0 ([#4807](https://github.com/getsentry/sentry-react-native/pull/4807)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8500) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.2...8.50.0) + ## 6.14.0 ### Fixes diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 8bdac45063..52deb19096 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.19.0", + "version": "7.0.0-rc.1", "private": true, "description": "Sentry React Native End to End Tests Library", "main": "dist/index.js", @@ -13,8 +13,8 @@ "devDependencies": { "@babel/preset-env": "^7.25.3", "@babel/preset-typescript": "^7.18.6", - "@sentry/core": "8.55.0", - "@sentry/react-native": "6.19.0", + "@sentry/core": "9.22.0", + "@sentry/react-native": "7.0.0-rc.1", "@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 a8df646b21..28e6a67803 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.19.0", + "version": "7.0.0-rc.1", "scripts": { "type-check": "./run-type-check.sh" } diff --git a/dev-packages/type-check/ts3.8-test/index.ts b/dev-packages/type-check/ts3.8-test/index.ts index 1e9fda3cd2..d6cc248482 100644 --- a/dev-packages/type-check/ts3.8-test/index.ts +++ b/dev-packages/type-check/ts3.8-test/index.ts @@ -3,6 +3,8 @@ declare global { interface IDBObjectStore {} interface Window { fetch: any; + setTimeout: any; + document: any; } interface ShadowRoot {} interface BufferSource {} @@ -19,6 +21,8 @@ declare global { redirectCount: number; } interface PerformanceEntry {} + interface Performance {} + interface PerformanceNavigationTiming {} } declare module 'react-native' { diff --git a/dev-packages/utils/package.json b/dev-packages/utils/package.json index 3509fb6ae6..057d56d84c 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.19.0", + "version": "7.0.0-rc.1", "description": "Internal Samples Utils", "main": "index.js", "license": "MIT", diff --git a/lerna.json b/lerna.json index 31e6ebb1f4..0d78d53d21 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "6.19.0", + "version": "7.0.0-rc.1", "packages": [ "packages/*", "dev-packages/*", diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 7bfc28b1e7..3ad06277ab 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -40,6 +40,7 @@ module.exports = { '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/unbound-method': 'off', + 'import/first': 'off', }, }, { 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 11b477a0b6..63b10812fe 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 @@ -185,4 +185,91 @@ class RNSentryModuleImplTest { assertEquals(breadcrumb, result) } + + @Test + fun `trySetIgnoreErrors sets only regex patterns`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "ignoreErrorsRegex", + com.facebook.react.bridge.JavaOnlyArray + .of("^Foo.*", "Bar$"), + ) + module.trySetIgnoreErrors(options, rnOptions) + assertEquals(listOf("^Foo.*", "Bar$"), options.ignoredErrors!!.map { it.filterString }) + } + + @Test + fun `trySetIgnoreErrors sets only string patterns`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "ignoreErrorsStr", + com.facebook.react.bridge.JavaOnlyArray + .of("ExactError", "AnotherError"), + ) + module.trySetIgnoreErrors(options, rnOptions) + assertEquals(listOf(".*\\QExactError\\E.*", ".*\\QAnotherError\\E.*"), options.ignoredErrors!!.map { it.filterString }) + } + + @Test + fun `trySetIgnoreErrors sets both regex and string patterns`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "ignoreErrorsRegex", + com.facebook.react.bridge.JavaOnlyArray + .of("^Foo.*"), + "ignoreErrorsStr", + com.facebook.react.bridge.JavaOnlyArray + .of("ExactError"), + ) + module.trySetIgnoreErrors(options, rnOptions) + assertEquals(listOf("^Foo.*", ".*\\QExactError\\E.*"), options.ignoredErrors!!.map { it.filterString }) + } + + @Test + fun `trySetIgnoreErrors sets nothing if neither is present`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of() + module.trySetIgnoreErrors(options, rnOptions) + assertNull(options.ignoredErrors) + } + + @Test + fun `trySetIgnoreErrors with string containing regex special characters should match literally if Pattern_quote is used`() { + val options = SentryAndroidOptions() + val special = "I like chocolate (and tomato)." + val rnOptions = + JavaOnlyMap.of( + "ignoreErrorsStr", + com.facebook.react.bridge.JavaOnlyArray + .of(special), + ) + module.trySetIgnoreErrors(options, rnOptions) + + assertEquals(listOf(".*\\QI like chocolate (and tomato).\\E.*"), options.ignoredErrors!!.map { it.filterString }) + + val regex = Regex(options.ignoredErrors!![0].filterString) + assertTrue(regex.matches("I like chocolate (and tomato).")) + assertTrue(regex.matches(" I like chocolate (and tomato). ")) + assertTrue(regex.matches("I like chocolate (and tomato). And vanilla.")) + } + + @Test + fun `trySetIgnoreErrors with string containing star should not match everything if Pattern_quote is used`() { + val options = SentryAndroidOptions() + val special = "Error*WithStar" + val rnOptions = + JavaOnlyMap.of( + "ignoreErrorsStr", + com.facebook.react.bridge.JavaOnlyArray + .of(special), + ) + module.trySetIgnoreErrors(options, rnOptions) + assertEquals(listOf(".*\\QError*WithStar\\E.*"), options.ignoredErrors!!.map { it.filterString }) + + val regex = Regex(options.ignoredErrors!![0].filterString) + assertTrue(regex.matches("Error*WithStar")) + } } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift index 3b8765547f..ff3dccba6f 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift @@ -192,7 +192,7 @@ final class RNSentryReplayOptions: XCTestCase { XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0) } - func testEnableViewRendererV2Default() { + func testEnableExperimentalViewRendererDefault() { let optionsDict = ([ "dsn": "https://abc@def.ingest.sentry.io/1234567", "replaysOnErrorSampleRate": 0.75 @@ -206,10 +206,10 @@ final class RNSentryReplayOptions: XCTestCase { let actualOptions = try! Options(dict: optionsDict as! [String: Any]) #endif - XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2) + XCTAssertTrue(actualOptions.sessionReplay.enableExperimentalViewRenderer) } - func testEnableViewRendererV2True() { + func testEnableExperimentalViewRendererTrue() { let optionsDict = ([ "dsn": "https://abc@def.ingest.sentry.io/1234567", "replaysOnErrorSampleRate": 0.75, @@ -227,7 +227,7 @@ final class RNSentryReplayOptions: XCTestCase { XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2) } - func testEnableViewRendererV2False() { + func testEnableExperimentalViewRendererFalse() { let optionsDict = ([ "dsn": "https://abc@def.ingest.sentry.io/1234567", "replaysOnErrorSampleRate": 0.75, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m index 2020a8e5a3..b7940cad2b 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m @@ -582,4 +582,119 @@ - (void)testFetchNativeStackFramesByInstructionsOnDeviceSymbolication XCTAssertTrue([actual isEqualToDictionary:expected]); } +- (void)testIgnoreErrorsDropsMatchingExceptionValue +{ + RNSentry *rnSentry = [[RNSentry alloc] init]; + NSError *error = nil; + NSDictionary *mockedOptions = @{ + @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", + @"ignoreErrorsRegex" : @[ @"IgnoreMe.*" ] + }; + SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedOptions error:&error]; + XCTAssertNotNil(options); + XCTAssertNil(error); + SentryEvent *event = [[SentryEvent alloc] init]; + SentryException *exception = [SentryException alloc]; + exception.value = @"IgnoreMe: This should be ignored"; + event.exceptions = @[ exception ]; + SentryEvent *result = options.beforeSend(event); + XCTAssertNil(result, @"Event with matching exception.value should be dropped"); +} + +- (void)testIgnoreErrorsDropsMatchingEventMessage +{ + RNSentry *rnSentry = [[RNSentry alloc] init]; + NSError *error = nil; + NSDictionary *mockedOptions = @{ + @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", + @"ignoreErrorsStr" : @[ @"DropThisError" ] + }; + SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedOptions error:&error]; + XCTAssertNotNil(options); + XCTAssertNil(error); + SentryEvent *event = [[SentryEvent alloc] init]; + SentryMessage *msg = [SentryMessage alloc]; + msg.message = @"DropThisError: should be dropped"; + event.message = msg; + SentryEvent *result = options.beforeSend(event); + XCTAssertNil(result, @"Event with matching event.message.formatted should be dropped"); +} + +- (void)testIgnoreErrorsDoesNotDropNonMatchingEvent +{ + RNSentry *rnSentry = [[RNSentry alloc] init]; + NSError *error = nil; + NSDictionary *mockedOptions = @{ + @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", + @"ignoreErrorsRegex" : @[ @"IgnoreMe.*" ] + }; + SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedOptions error:&error]; + XCTAssertNotNil(options); + XCTAssertNil(error); + SentryEvent *event = [[SentryEvent alloc] init]; + SentryException *exception = [SentryException alloc]; + exception.value = @"SomeOtherError: should not be ignored"; + event.exceptions = @[ exception ]; + SentryMessage *msg = [SentryMessage alloc]; + msg.message = @"SomeOtherMessage"; + event.message = msg; + SentryEvent *result = options.beforeSend(event); + XCTAssertNotNil(result, @"Event with non-matching error should not be dropped"); +} + +- (void)testIgnoreErrorsDropsMatchingExactString +{ + RNSentry *rnSentry = [[RNSentry alloc] init]; + NSError *error = nil; + NSDictionary *mockedOptions = @{ + @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", + @"ignoreErrorsStr" : @[ @"ExactError" ] + }; + SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedOptions error:&error]; + XCTAssertNotNil(options); + XCTAssertNil(error); + SentryEvent *event = [[SentryEvent alloc] init]; + SentryMessage *msg = [SentryMessage alloc]; + msg.message = @"ExactError"; + event.message = msg; + SentryEvent *result = options.beforeSend(event); + XCTAssertNil(result, @"Event with exactly matching string should be dropped"); +} + +- (void)testIgnoreErrorsRegexAndStringBothWork +{ + RNSentry *rnSentry = [[RNSentry alloc] init]; + NSError *error = nil; + NSDictionary *mockedOptions = @{ + @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", + @"ignoreErrorsStr" : @[ @"ExactError" ], + @"ignoreErrorsRegex" : @[ @"IgnoreMe.*" ], + + }; + SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedOptions error:&error]; + XCTAssertNotNil(options); + XCTAssertNil(error); + // Test regex match + SentryEvent *event1 = [[SentryEvent alloc] init]; + SentryException *exception = [SentryException alloc]; + exception.value = @"IgnoreMe: This should be ignored"; + event1.exceptions = @[ exception ]; + SentryEvent *result1 = options.beforeSend(event1); + XCTAssertNil(result1, @"Event with matching regex should be dropped"); + // Test exact string match + SentryEvent *event2 = [[SentryEvent alloc] init]; + SentryMessage *msg = [SentryMessage alloc]; + msg.message = @"ExactError"; + event2.message = msg; + SentryEvent *result2 = options.beforeSend(event2); + XCTAssertNil(result2, @"Event with exactly matching string should be dropped"); + // Test non-matching + SentryEvent *event3 = [[SentryEvent alloc] init]; + SentryMessage *msg3 = [SentryMessage alloc]; + msg3.message = @"OtherError"; + event3.message = msg3; + SentryEvent *result3 = options.beforeSend(event3); + XCTAssertNotNil(result3, @"Event with non-matching error should not be dropped"); +} + @end diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 59d92f087f..68e4396811 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -54,5 +54,5 @@ android { dependencies { implementation 'com.facebook.react:react-native:+' - api 'io.sentry:sentry-android:7.22.6' + api 'io.sentry:sentry-android:8.19.0' } 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 ca6ecadacd..c1da1b9f07 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 @@ -31,12 +31,12 @@ import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.common.JavascriptException; import io.sentry.Breadcrumb; -import io.sentry.HubAdapter; import io.sentry.ILogger; import io.sentry.IScope; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; import io.sentry.Integration; +import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; @@ -84,13 +84,16 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -280,6 +283,12 @@ protected void getSentryAndroidOptions( if (rnOptions.hasKey("enableNdk")) { options.setEnableNdk(rnOptions.getBoolean("enableNdk")); } + if (rnOptions.hasKey("_experiments")) { + ReadableMap experiments = rnOptions.getMap("_experiments"); + if (experiments.hasKey("enableLogs")) { + options.getLogs().setEnabled(experiments.getBoolean("enableLogs")); + } + } if (rnOptions.hasKey("spotlight")) { if (rnOptions.getType("spotlight") == ReadableType.Boolean) { options.setEnableSpotlight(rnOptions.getBoolean("spotlight")); @@ -315,6 +324,8 @@ protected void getSentryAndroidOptions( // we want to ignore it on the native side to avoid sending it twice. options.addIgnoredExceptionForType(JavascriptException.class); + trySetIgnoreErrors(options, rnOptions); + options.setBeforeSend( (event, hint) -> { setEventOriginTag(event); @@ -557,7 +568,7 @@ public void fetchNativeFrames(Promise promise) { } public void captureReplay(boolean isHardCrash, Promise promise) { - Sentry.getCurrentHub().getOptions().getReplayController().captureReplay(isHardCrash); + Sentry.getCurrentScopes().getOptions().getReplayController().captureReplay(isHardCrash); promise.resolve(getCurrentReplayId()); } @@ -653,7 +664,7 @@ public void fetchViewHierarchy(Promise promise) { return; } - ISerializer serializer = HubAdapter.getInstance().getOptions().getSerializer(); + ISerializer serializer = ScopesAdapter.getInstance().getOptions().getSerializer(); final @Nullable byte[] bytes = JsonSerializationUtils.bytesFrom(serializer, logger, viewHierarchy); if (bytes == null) { @@ -707,10 +718,6 @@ public void setUser(final ReadableMap userKeys, final ReadableMap userDataKeys) if (userKeys.hasKey("ip_address")) { userInstance.setIpAddress(userKeys.getString("ip_address")); } - - if (userKeys.hasKey("segment")) { - userInstance.setSegment(userKeys.getString("segment")); - } } if (userDataKeys != null) { @@ -872,8 +879,7 @@ private void initializeAndroidProfiler() { (int) SECONDS.toMicros(1) / profilingTracesHz, new SentryFrameMetricsCollector(reactApplicationContext, logger, buildInfo), executorService, - logger, - buildInfo); + logger); } public WritableMap startProfiling(boolean platformProfilers) { @@ -897,7 +903,7 @@ public WritableMap startProfiling(boolean platformProfilers) { } public WritableMap stopProfiling() { - final boolean isDebug = HubAdapter.getInstance().getOptions().isDebug(); + final boolean isDebug = ScopesAdapter.getInstance().getOptions().isDebug(); final WritableMap result = new WritableNativeMap(); File output = null; try { @@ -982,8 +988,15 @@ private String readStringFromFile(File path) throws IOException { } } + public void fetchNativeLogAttributes(Promise promise) { + final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions(); + final @Nullable Context context = this.getReactApplicationContext().getApplicationContext(); + final @Nullable IScope currentScope = InternalSentrySdk.getCurrentScope(); + fetchNativeLogContexts(promise, options, context, currentScope); + } + public void fetchNativeDeviceContexts(Promise promise) { - final @NotNull SentryOptions options = HubAdapter.getInstance().getOptions(); + final @NotNull SentryOptions options = ScopesAdapter.getInstance().getOptions(); final @Nullable Context context = this.getReactApplicationContext().getApplicationContext(); final @Nullable IScope currentScope = InternalSentrySdk.getCurrentScope(); fetchNativeDeviceContexts(promise, options, context, currentScope); @@ -1019,8 +1032,50 @@ protected void fetchNativeDeviceContexts( promise.resolve(deviceContext); } + // Basically fetchNativeDeviceContexts but filtered to only get contexts info. + protected void fetchNativeLogContexts( + Promise promise, + final @NotNull SentryOptions options, + final @Nullable Context osContext, + final @Nullable IScope currentScope) { + if (!(options instanceof SentryAndroidOptions) || osContext == null) { + promise.resolve(null); + return; + } + + Object contextsObj = + InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); + + if (!(contextsObj instanceof Map)) { + promise.resolve(null); + return; + } + + @SuppressWarnings("unchecked") + Map contextsMap = (Map) contextsObj; + + Map contextItems = new HashMap<>(); + if (contextsMap.containsKey("os")) { + contextItems.put("os", contextsMap.get("os")); + } + + if (contextsMap.containsKey("device")) { + contextItems.put("device", contextsMap.get("device")); + } + + contextItems.put("release", options.getRelease()); + + Map logContext = new HashMap<>(); + logContext.put("contexts", contextItems); + Object filteredContext = RNSentryMapConverter.convertToWritable(logContext); + + promise.resolve(filteredContext); + } + public void fetchNativeSdkInfo(Promise promise) { - final @Nullable SdkVersion sdkVersion = HubAdapter.getInstance().getOptions().getSdkVersion(); + final @Nullable SdkVersion sdkVersion = + ScopesAdapter.getInstance().getOptions().getSdkVersion(); if (sdkVersion == null) { promise.resolve(null); } else { @@ -1108,14 +1163,14 @@ private void addPackages(SentryEvent event, SdkVersion sdk) { if (eventSdk != null && "sentry.javascript.react-native".equals(eventSdk.getName()) && sdk != null) { - List sentryPackages = sdk.getPackages(); + Set sentryPackages = sdk.getPackageSet(); if (sentryPackages != null) { for (SentryPackage sentryPackage : sentryPackages) { eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); } } - List integrations = sdk.getIntegrations(); + Set integrations = sdk.getIntegrationSet(); if (integrations != null) { for (String integration : integrations) { eventSdk.addIntegration(integration); @@ -1152,4 +1207,36 @@ private boolean isFrameMetricsAggregatorAvailable() { } return uri.getScheme() + "://" + uri.getHost(); } + + @TestOnly + protected void trySetIgnoreErrors(SentryAndroidOptions options, ReadableMap rnOptions) { + ReadableArray regErrors = null; + ReadableArray strErrors = null; + if (rnOptions.hasKey("ignoreErrorsRegex")) { + regErrors = rnOptions.getArray("ignoreErrorsRegex"); + } + if (rnOptions.hasKey("ignoreErrorsStr")) { + strErrors = rnOptions.getArray("ignoreErrorsStr"); + } + if (regErrors == null && strErrors == null) { + return; + } + + int regSize = regErrors != null ? regErrors.size() : 0; + int strSize = strErrors != null ? strErrors.size() : 0; + List list = new ArrayList<>(regSize + strSize); + if (regErrors != null) { + for (int i = 0; i < regErrors.size(); i++) { + list.add(regErrors.getString(i)); + } + } + if (strErrors != null) { + // Use the same behaviour of JavaScript instead of Android when dealing with strings. + for (int i = 0; i < strErrors.size(); i++) { + String pattern = ".*" + Pattern.quote(strErrors.getString(i)) + ".*"; + list.add(pattern); + } + } + options.setIgnoredErrors(list); + } } 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 e8733c0134..9668f53f06 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.19.0"; + static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "7.0.0-rc.1"; 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/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 5b14f05c92..993969d830 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -127,6 +127,11 @@ public void disableNativeFramesTracking() { this.impl.disableNativeFramesTracking(); } + @Override + public void fetchNativeLogAttributes(Promise promise) { + this.impl.fetchNativeLogAttributes(promise); + } + @Override public void fetchNativeDeviceContexts(Promise promise) { this.impl.fetchNativeDeviceContexts(promise); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 108631766a..ed5cb4b459 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -71,6 +71,8 @@ @implementation RNSentry { bool sentHybridSdkDidBecomeActive; bool hasListeners; RNSentryTimeToDisplay *_timeToDisplay; + NSArray *_ignoreErrorPatternsStr; + NSArray *_ignoreErrorPatternsRegex; } - (dispatch_queue_t)methodQueue @@ -92,7 +94,6 @@ - (instancetype)init } RCT_EXPORT_MODULE() - RCT_EXPORT_METHOD(initNativeSdk : (NSDictionary *_Nonnull)options resolve : (RCTPromiseResolveBlock)resolve rejecter @@ -137,21 +138,98 @@ - (instancetype)init resolve(@YES); } +- (void)trySetIgnoreErrors:(NSMutableDictionary *)options +{ + NSArray *ignoreErrorsStr = nil; + NSArray *ignoreErrorsRegex = nil; + + id strArr = [options objectForKey:@"ignoreErrorsStr"]; + id regexArr = [options objectForKey:@"ignoreErrorsRegex"]; + if ([strArr isKindOfClass:[NSArray class]]) { + ignoreErrorsStr = (NSArray *)strArr; + } + if ([regexArr isKindOfClass:[NSArray class]]) { + ignoreErrorsRegex = (NSArray *)regexArr; + } + + NSMutableArray *strs = [NSMutableArray array]; + NSMutableArray *regexes = [NSMutableArray array]; + + if (ignoreErrorsStr != nil) { + for (id str in ignoreErrorsStr) { + if ([str isKindOfClass:[NSString class]]) { + [strs addObject:str]; + } + } + } + + if (ignoreErrorsRegex != nil) { + for (id pattern in ignoreErrorsRegex) { + if ([pattern isKindOfClass:[NSString class]]) { + NSError *error = nil; + NSRegularExpression *regex = + [NSRegularExpression regularExpressionWithPattern:pattern + options:0 + error:&error]; + if (regex && error == nil) { + [regexes addObject:regex]; + } + } + } + } + + _ignoreErrorPatternsStr = [strs count] > 0 ? [strs copy] : nil; + _ignoreErrorPatternsRegex = [regexes count] > 0 ? [regexes copy] : nil; +} + +- (BOOL)shouldIgnoreError:(NSString *)message +{ + if ((!_ignoreErrorPatternsStr && !_ignoreErrorPatternsRegex) || !message) { + return NO; + } + + for (NSString *str in _ignoreErrorPatternsStr) { + if ([message containsString:str]) { + return YES; + } + } + + for (NSRegularExpression *regex in _ignoreErrorPatternsRegex) { + NSRange range = NSMakeRange(0, message.length); + if ([regex firstMatchInString:message options:0 range:range]) { + return YES; + } + } + + return NO; +} + - (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. + // 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]; + // Regex and Str are set when one of them has value so we only need to check one of them. + if (self->_ignoreErrorPatternsStr || self->_ignoreErrorPatternsRegex) { + for (SentryException *exception in event.exceptions) { + if ([self shouldIgnoreError:exception.value]) { + return nil; + } + } + if ([self shouldIgnoreError:event.message.message]) { + return nil; + } + } + [self setEventOriginTag:event]; return event; }; @@ -222,6 +300,8 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) } } + [self trySetIgnoreErrors:mutableOptions]; + // Enable the App start and Frames tracking measurements if ([mutableOptions valueForKey:@"enableAutoPerformanceTracing"] != nil) { BOOL enableAutoPerformanceTracing = @@ -443,6 +523,58 @@ - (NSDictionary *)fetchNativeStackFramesBy:(NSArray *)instructionsAd return [self fetchNativeStackFramesBy:instructionsAddr symbolicate:dladdr]; } +RCT_EXPORT_METHOD(fetchNativeLogAttributes + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + __block NSMutableDictionary *result = [NSMutableDictionary new]; + + [SentrySDKWrapper configureScope:^(SentryScope *_Nonnull scope) { + // Serialize to get contexts dictionary + NSDictionary *serializedScope = [scope serialize]; + NSDictionary *allContexts = serializedScope[@"context"]; // It's singular here, annoyingly + + NSMutableDictionary *contexts = [NSMutableDictionary new]; + + NSDictionary *device = allContexts[@"device"]; + if ([device isKindOfClass:[NSDictionary class]]) { + contexts[@"device"] = device; + } + + NSDictionary *os = allContexts[@"os"]; + if ([os isKindOfClass:[NSDictionary class]]) { + contexts[@"os"] = os; + } + + NSString *releaseName = [SentrySDK options].releaseName; + if (releaseName) { + contexts[@"release"] = releaseName; + } + // Merge extra context + NSDictionary *extraContext = [PrivateSentrySDKOnly getExtraContext]; + + if (extraContext) { + NSDictionary *extraDevice = extraContext[@"device"]; + if ([extraDevice isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *mergedDevice = + [contexts[@"device"] mutableCopy] ?: [NSMutableDictionary new]; + [mergedDevice addEntriesFromDictionary:extraDevice]; + contexts[@"device"] = mergedDevice; + } + + NSDictionary *extraOS = extraContext[@"os"]; + if ([extraOS isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *mergedOS = + [contexts[@"os"] mutableCopy] ?: [NSMutableDictionary new]; + [mergedOS addEntriesFromDictionary:extraOS]; + contexts[@"os"] = mergedOS; + } + } + result[@"contexts"] = contexts; + }]; + resolve(result); +} + RCT_EXPORT_METHOD(fetchNativeDeviceContexts : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) diff --git a/packages/core/ios/RNSentryVersion.m b/packages/core/ios/RNSentryVersion.m index 644e3d596f..7ec95ae3c2 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.19.0"; +NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"7.0.0-rc.1"; diff --git a/packages/core/package.json b/packages/core/package.json index 821d9b1158..3b2b8ce95f 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.19.0", + "version": "7.0.0-rc.1", "description": "Official Sentry SDK for react-native", "typings": "dist/js/index.d.ts", "types": "dist/js/index.d.ts", @@ -66,21 +66,20 @@ }, "dependencies": { "@sentry/babel-plugin-component-annotate": "4.0.2", - "@sentry/browser": "8.55.0", + "@sentry/browser": "9.22.0", "@sentry/cli": "2.50.2", - "@sentry/core": "8.55.0", - "@sentry/react": "8.55.0", - "@sentry/types": "8.55.0", - "@sentry/utils": "8.55.0" + "@sentry/core": "9.22.0", + "@sentry/react": "9.22.0", + "@sentry/types": "9.22.0" }, "devDependencies": { "@babel/core": "^7.25.2", "@expo/metro-config": "~0.20.0", "@mswjs/interceptors": "^0.25.15", "@react-native/babel-preset": "0.77.1", - "@sentry-internal/eslint-config-sdk": "8.55.0", - "@sentry-internal/eslint-plugin-sdk": "8.55.0", - "@sentry-internal/typescript": "8.55.0", + "@sentry-internal/eslint-config-sdk": "9.22.0", + "@sentry-internal/eslint-plugin-sdk": "9.22.0", + "@sentry-internal/typescript": "9.22.0", "@sentry/wizard": "6.1.0", "@testing-library/react-native": "^12.7.2", "@types/jest": "^29.5.13", @@ -110,7 +109,7 @@ "react-native": "0.77.1", "react-test-renderer": "^18.3.1", "rimraf": "^4.1.1", - "ts-jest": "^29.1.1", + "ts-jest": "^29.3.1", "typescript": "4.9.5", "uglify-js": "^3.17.4", "uuid": "^9.0.1", diff --git a/packages/core/plugin/src/withSentry.ts b/packages/core/plugin/src/withSentry.ts index 70d4c8932b..2fdee7f063 100644 --- a/packages/core/plugin/src/withSentry.ts +++ b/packages/core/plugin/src/withSentry.ts @@ -1,6 +1,5 @@ import type { ConfigPlugin } from 'expo/config-plugins'; import { createRunOncePlugin } from 'expo/config-plugins'; - import { bold, sdkPackage, warnOnce } from './utils'; import { withSentryAndroid } from './withSentryAndroid'; import type { SentryAndroidGradlePluginOptions } from './withSentryAndroidGradlePlugin'; @@ -18,7 +17,7 @@ interface PluginProps { const withSentryPlugin: ConfigPlugin = (config, props) => { const sentryProperties = getSentryProperties(props); - if (props && props.authToken) { + if (props?.authToken) { // If not removed, the plugin config with the authToken will be written to the application package delete props.authToken; } @@ -50,8 +49,9 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { const missingProjectMessage = '# no project found, falling back to SENTRY_PROJECT environment variable'; const missingOrgMessage = '# no org found, falling back to SENTRY_ORG environment variable'; -const existingAuthTokenMessage = `# DO NOT COMMIT the auth token, use SENTRY_AUTH_TOKEN instead, see https://docs.sentry.io/platforms/react-native/manual-setup/`; -const missingAuthTokenMessage = `# Using SENTRY_AUTH_TOKEN environment variable`; +const existingAuthTokenMessage = + '# DO NOT COMMIT the auth token, use SENTRY_AUTH_TOKEN instead, see https://docs.sentry.io/platforms/react-native/manual-setup/'; +const missingAuthTokenMessage = '# Using SENTRY_AUTH_TOKEN environment variable'; export function getSentryProperties(props: PluginProps | void): string | null { const { organization, project, authToken, url = 'https://sentry.io/' } = props ?? {}; diff --git a/packages/core/plugin/src/withSentryAndroid.ts b/packages/core/plugin/src/withSentryAndroid.ts index 9beaa23883..e8c2cf243e 100644 --- a/packages/core/plugin/src/withSentryAndroid.ts +++ b/packages/core/plugin/src/withSentryAndroid.ts @@ -1,7 +1,6 @@ import type { ConfigPlugin } from 'expo/config-plugins'; import { withAppBuildGradle, withDangerousMod } from 'expo/config-plugins'; import * as path from 'path'; - import { warnOnce, writeSentryPropertiesTo } from './utils'; export const withSentryAndroid: ConfigPlugin = (config, sentryProperties: string) => { diff --git a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts index 8efc873c00..b37fe8afaa 100644 --- a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts +++ b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts @@ -1,5 +1,4 @@ import { withAppBuildGradle, withProjectBuildGradle } from '@expo/config-plugins'; - import { warnOnce } from './utils'; export interface SentryAndroidGradlePluginOptions { @@ -35,7 +34,7 @@ export function withSentryAndroidGradlePlugin( const withSentryProjectBuildGradle = (config: any): any => { return withProjectBuildGradle(config, (projectBuildGradle: any) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (!projectBuildGradle.modResults || !projectBuildGradle.modResults.contents) { + if (!projectBuildGradle.modResults?.contents) { warnOnce('android/build.gradle content is missing or undefined.'); return config; } @@ -67,7 +66,7 @@ export function withSentryAndroidGradlePlugin( projectBuildGradle.modResults.contents = updatedContents; } } catch (error) { - warnOnce(`An error occurred while trying to modify build.gradle`); + warnOnce('An error occurred while trying to modify build.gradle'); } return projectBuildGradle; }); @@ -81,7 +80,7 @@ export function withSentryAndroidGradlePlugin( warnOnce('Cannot configure Sentry in android/app/build.gradle because it is not in Groovy.'); return config; } - const sentryPlugin = `apply plugin: "io.sentry.android.gradle"`; + const sentryPlugin = 'apply plugin: "io.sentry.android.gradle"'; const sentryConfig = ` sentry { autoUploadProguardMapping = ${autoUploadProguardMapping ? 'shouldSentryAutoUpload()' : 'false'} diff --git a/packages/core/plugin/src/withSentryIOS.ts b/packages/core/plugin/src/withSentryIOS.ts index db25261839..e10f820282 100644 --- a/packages/core/plugin/src/withSentryIOS.ts +++ b/packages/core/plugin/src/withSentryIOS.ts @@ -2,7 +2,6 @@ import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins'; import { withDangerousMod, withXcodeProject } from 'expo/config-plugins'; import * as path from 'path'; - import { warnOnce, writeSentryPropertiesTo } from './utils'; type BuildPhase = { shellScript: string }; diff --git a/packages/core/scripts/expo-upload-sourcemaps.js b/packages/core/scripts/expo-upload-sourcemaps.js index 8c4680d196..903b9fc657 100755 --- a/packages/core/scripts/expo-upload-sourcemaps.js +++ b/packages/core/scripts/expo-upload-sourcemaps.js @@ -177,7 +177,7 @@ if (!sentryOrg || !sentryProject || !sentryUrl) { console.log(`${SENTRY_URL} resolved to ${sentryUrl} from expo config.`); } else { - sentryUrl = `https://sentry.io/`; + sentryUrl = 'https://sentry.io/'; console.log( `Since it wasn't specified in the Expo config or environment variable, ${SENTRY_URL} now points to ${sentryUrl}.` ); @@ -217,7 +217,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) { } const isHermes = assets.find(asset => asset.endsWith('.hbc')); - const windowsCallback = process.platform === "win32" ? 'node ' : ''; + const windowsCallback = process.platform === 'win32' ? 'node ' : ''; execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, { env: { ...process.env, diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 5b00b62116..cdfbb0d781 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -1,7 +1,6 @@ import type { Package } from '@sentry/core'; import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; - import type { UnsafeObject } from './utils/rnlibrariesinterface'; // There has to be only one interface and it has to be named `Spec` @@ -25,6 +24,7 @@ export interface Spec extends TurboModule { fetchNativeRelease(): Promise; fetchNativeSdkInfo(): Promise; fetchNativeDeviceContexts(): Promise; + fetchNativeLogAttributes(): Promise; fetchNativeAppStart(): Promise; fetchNativeFrames(): Promise; initNativeSdk(options: UnsafeObject): Promise; diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index 6fcad6e513..957aae9f5c 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -10,9 +10,16 @@ import type { TransportMakeRequestResponse, UserFeedback, } from '@sentry/core'; -import { BaseClient, dateTimestampInSeconds, logger, SentryError } from '@sentry/core'; +import { + _INTERNAL_flushLogsBuffer, + addAutoIpAddressToSession, + addAutoIpAddressToUser, + BaseClient, + dateTimestampInSeconds, + logger, + SentryError, +} from '@sentry/core'; import { Alert } from 'react-native'; - import { getDevServer } from './integrations/debugsymbolicatorutils'; import { defaultSdkInfo } from './integrations/sdkinfo'; import { getDefaultSidecarUrl } from './integrations/spotlight'; @@ -25,6 +32,8 @@ import { mergeOutcomes } from './utils/outcome'; import { ReactNativeLibraries } from './utils/rnlibraries'; import { NATIVE } from './wrapper'; +const DEFAULT_FLUSH_INTERVAL = 5000; + /** * The Sentry React Native SDK Client. * @@ -33,6 +42,7 @@ import { NATIVE } from './wrapper'; */ export class ReactNativeClient extends BaseClient { private _outcomesBuffer: Outcome[]; + private _logFlushIdleTimeout: ReturnType | undefined; /** * Creates a new React Native SDK instance. @@ -48,6 +58,27 @@ export class ReactNativeClient extends BaseClient { super(options); this._outcomesBuffer = []; + + if (options.sendDefaultPii === true) { + this.on('postprocessEvent', addAutoIpAddressToUser); + this.on('beforeSendSession', addAutoIpAddressToSession); + } + + if (options._experiments?.enableLogs) { + this.on('flush', () => { + _INTERNAL_flushLogsBuffer(this); + }); + + this.on('afterCaptureLog', () => { + if (this._logFlushIdleTimeout) { + clearTimeout(this._logFlushIdleTimeout); + } + + this._logFlushIdleTimeout = setTimeout(() => { + _INTERNAL_flushLogsBuffer(this); + }, DEFAULT_FLUSH_INTERVAL); + }); + } } /** diff --git a/packages/core/src/js/feedback/FeedbackButton.tsx b/packages/core/src/js/feedback/FeedbackButton.tsx index fbb546db8d..66ab86b07f 100644 --- a/packages/core/src/js/feedback/FeedbackButton.tsx +++ b/packages/core/src/js/feedback/FeedbackButton.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import type { NativeEventSubscription} from 'react-native'; import { Appearance, Image, Text, TouchableOpacity } from 'react-native'; - import { defaultButtonConfiguration } from './defaults'; import { defaultButtonStyles } from './FeedbackWidget.styles'; import { getTheme } from './FeedbackWidget.theme'; @@ -15,7 +14,7 @@ import { lazyLoadFeedbackIntegration } from './lazy'; * Implements a feedback button that opens the FeedbackForm. */ export class FeedbackButton extends React.Component { - private _themeListener: NativeEventSubscription; + private _themeListener: NativeEventSubscription | undefined; public constructor(props: FeedbackButtonProps) { super(props); @@ -58,8 +57,10 @@ export class FeedbackButton extends React.Component { onPress={showFeedbackWidget} accessibilityLabel={text.triggerAriaLabel} > - - {text.triggerLabel} + + + {text.triggerLabel} + ); } diff --git a/packages/core/src/js/feedback/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts index 94df799d21..8620d8c9b3 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.styles.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts @@ -1,5 +1,4 @@ import type { ViewStyle } from 'react-native'; - import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; import type { FeedbackButtonStyles, FeedbackWidgetStyles } from './FeedbackWidget.types'; diff --git a/packages/core/src/js/feedback/FeedbackWidget.theme.ts b/packages/core/src/js/feedback/FeedbackWidget.theme.ts index aa8711a934..602b6bdea3 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.theme.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.theme.ts @@ -1,5 +1,4 @@ import { Appearance } from 'react-native'; - import { getColorScheme, getFeedbackDarkTheme, getFeedbackLightTheme } from './integration'; /** diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx index ad029377dd..009c5da68f 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.tsx +++ b/packages/core/src/js/feedback/FeedbackWidget.tsx @@ -14,7 +14,6 @@ import { TouchableWithoutFeedback, View } from 'react-native'; - import { isExpoGo, isWeb, notWeb } from '../utils/environment'; import type { Screenshot } from '../wrapper'; import { getDataFromUri, NATIVE } from '../wrapper'; @@ -33,9 +32,7 @@ import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils' * Implements a feedback form screen that sends feedback to Sentry using Sentry.captureFeedback. */ export class FeedbackWidget extends React.Component { - public static defaultProps: Partial = { - ...defaultConfiguration - } + public static defaultProps = defaultConfiguration; private static _savedState: Omit = { name: '', @@ -46,7 +43,7 @@ export class FeedbackWidget extends React.Component void = () => { const { name, email, description } = this.state; const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props; - const text: FeedbackTextConfiguration = this.props; + const text = this.props; const trimmedName = name?.trim(); const trimmedEmail = email?.trim(); const trimmedDescription = description?.trim(); - if ((this.props.isNameRequired && !trimmedName) || (this.props.isEmailRequired && !trimmedEmail) || !trimmedDescription) { + if ( + (this.props.isNameRequired && !trimmedName) || + (this.props.isEmailRequired && !trimmedEmail) || + !trimmedDescription + ) { feedbackAlertDialog(text.errorTitle, text.formError); return; } - if (this.props.shouldValidateEmail && (this.props.isEmailRequired || trimmedEmail.length > 0) && !isValidEmail(trimmedEmail)) { + if ( + this.props.shouldValidateEmail && + (this.props.isEmailRequired || trimmedEmail.length > 0) && + !isValidEmail(trimmedEmail) + ) { feedbackAlertDialog(text.errorTitle, text.emailError); return; } - const attachments = this.state.filename && this.state.attachment - ? [ - { - filename: this.state.filename, - data: this.state.attachment, - }, - ] - : undefined; + const attachments = + this.state.filename && this.state.attachment + ? [ + { + filename: this.state.filename, + data: this.state.attachment, + }, + ] + : undefined; const eventId = lastEventId(); const userFeedback: SendFeedbackParams = { @@ -128,8 +134,13 @@ export class FeedbackWidget extends React.Component void = async () => { if (!this._hasScreenshot()) { - const imagePickerConfiguration: ImagePickerConfiguration = this.props; - if (imagePickerConfiguration.imagePicker) { - const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync - // expo-image-picker library is available - ? () => imagePickerConfiguration.imagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], base64: isWeb() }) - // react-native-image-picker library is available - : imagePickerConfiguration.imagePicker.launchImageLibrary - ? () => imagePickerConfiguration.imagePicker.launchImageLibrary({ mediaType: 'photo', includeBase64: isWeb() }) - : null; + const { imagePicker } = this.props; + if (imagePicker) { + const launchImageLibrary = imagePicker.launchImageLibraryAsync + ? // expo-image-picker library is available + () => imagePicker.launchImageLibraryAsync?.({ mediaTypes: ['images'], base64: isWeb() }) + : // react-native-image-picker library is available + imagePicker.launchImageLibrary + ? () => imagePicker.launchImageLibrary?.({ mediaType: 'photo', includeBase64: isWeb() }) + : null; if (!launchImageLibrary) { logger.warn('No compatible image picker library found. Please provide a valid image picker library.'); if (__DEV__) { @@ -163,31 +174,34 @@ export class FeedbackWidget extends React.Component 0) { + if (result?.assets && result.assets.length > 0) { if (isWeb()) { - const filename = result.assets[0].fileName; - const imageUri = result.assets[0].uri; - const base64 = result.assets[0].base64; - const data = base64ToUint8Array(base64); - if (data != null) { + const filename = result.assets[0]?.fileName; + const imageUri = result.assets[0]?.uri; + const base64 = result.assets[0]?.base64; + const data = base64 ? base64ToUint8Array(base64) : undefined; + if (data) { this.setState({ filename, attachment: data, attachmentUri: imageUri }); } else { logger.error('Failed to read image data on the web'); } } else { - const filename = result.assets[0].fileName; - const imageUri = result.assets[0].uri; - getDataFromUri(imageUri).then((data) => { - if (data != null) { - this.setState({ filename, attachment: data, attachmentUri: imageUri }); - } else { - this._showImageRetrievalDevelopmentNote(); - logger.error('Failed to read image data from uri:', imageUri); - } - }).catch((error) => { - this._showImageRetrievalDevelopmentNote(); - logger.error('Failed to read image data from uri:', imageUri, 'error: ', error); - }); + const filename = result.assets[0]?.fileName; + const imageUri = result.assets[0]?.uri; + imageUri && + getDataFromUri(imageUri) + .then((data) => { + if (data != null) { + this.setState({ filename, attachment: data, attachmentUri: imageUri }); + } else { + this._showImageRetrievalDevelopmentNote(); + logger.error('Failed to read image data from uri:', imageUri); + } + }) + .catch((error) => { + this._showImageRetrievalDevelopmentNote(); + logger.error('Failed to read image data from uri:', imageUri, 'error: ', error); + }); } } } else { @@ -210,7 +224,7 @@ export class FeedbackWidget extends React.Component + > - {text.formTitle} + + {text.formTitle} + {config.showBranding && ( - + )} {config.showName && ( - <> - - {text.nameLabel} - {config.isNameRequired && ` ${text.isRequiredLabel}`} - - this.setState({ name: value })} - /> - + <> + + {text.nameLabel} + {config.isNameRequired && ` ${text.isRequiredLabel}`} + + this.setState({ name: value })} + /> + )} {config.showEmail && ( - <> - - {text.emailLabel} - {config.isEmailRequired && ` ${text.isRequiredLabel}`} - - this.setState({ email: value })} - /> - + <> + + {text.emailLabel} + {config.isEmailRequired && ` ${text.isRequiredLabel}`} + + this.setState({ email: value })} + /> + )} @@ -325,25 +337,20 @@ export class FeedbackWidget extends React.Component this.setState({ description: value })} + onChangeText={value => this.setState({ description: value })} multiline /> {(config.enableScreenshot || imagePickerConfiguration.imagePicker || this._hasScreenshot()) && ( {this.state.attachmentUri && ( - + )} - {!this._hasScreenshot() - ? text.addScreenshotButtonLabel - : text.removeScreenshotButtonLabel} + {!this._hasScreenshot() ? text.addScreenshotButtonLabel : text.removeScreenshotButtonLabel} @@ -358,7 +365,9 @@ export class FeedbackWidget extends React.Component )} - {text.submitButtonLabel} + + {text.submitButtonLabel} + @@ -372,20 +381,22 @@ export class FeedbackWidget extends React.Component { if (screenshot.data != null) { logger.debug('Setting captured screenshot:', screenshot.filename); - NATIVE.encodeToBase64(screenshot.data).then((base64String) => { - if (base64String != null) { - const dataUri = `data:${screenshot.contentType};base64,${base64String}`; - this.setState({ filename: screenshot.filename, attachment: screenshot.data, attachmentUri: dataUri }); - } else { - logger.error('Failed to read image data from:', screenshot.filename); - } - }).catch((error) => { - logger.error('Failed to read image data from:', screenshot.filename, 'error: ', error); - }); + NATIVE.encodeToBase64(screenshot.data) + .then(base64String => { + if (base64String != null) { + const dataUri = `data:${screenshot.contentType};base64,${base64String}`; + this.setState({ filename: screenshot.filename, attachment: screenshot.data, attachmentUri: dataUri }); + } else { + logger.error('Failed to read image data from:', screenshot.filename); + } + }) + .catch(error => { + logger.error('Failed to read image data from:', screenshot.filename, 'error: ', error); + }); } else { logger.error('Failed to read image data from:', screenshot.filename); } - } + }; private _saveFormState = (): void => { FeedbackWidget._savedState = { ...this.state }; diff --git a/packages/core/src/js/feedback/FeedbackWidget.types.ts b/packages/core/src/js/feedback/FeedbackWidget.types.ts index 22b6b0911f..d3878dcfb0 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.types.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.types.ts @@ -21,38 +21,38 @@ export interface FeedbackGeneralConfiguration { * * @default true */ - showBranding?: boolean; + showBranding: boolean; /** * Should the email field be required? */ - isEmailRequired?: boolean; + isEmailRequired: boolean; /** * Should the email field be validated? */ - shouldValidateEmail?: boolean; + shouldValidateEmail: boolean; /** * Should the name field be required? */ - isNameRequired?: boolean; + isNameRequired: boolean; /** * Should the email input field be visible? Note: email will still be collected if set via `Sentry.setUser()` */ - showEmail?: boolean; + showEmail: boolean; /** * Should the name input field be visible? Note: name will still be collected if set via `Sentry.setUser()` */ - showName?: boolean; + showName: boolean; /** * This flag determines whether the "Add Screenshot" button is displayed * @default false */ - enableScreenshot?: boolean; + enableScreenshot: boolean; /** * This flag determines whether the "Take Screenshot" button is displayed @@ -77,32 +77,32 @@ export interface FeedbackTextConfiguration { /** * The label for the Feedback form cancel button that closes dialog */ - cancelButtonLabel?: string; + cancelButtonLabel: string; /** * The label for the Feedback form submit button that sends feedback */ - submitButtonLabel?: string; + submitButtonLabel: string; /** * The title of the Feedback form */ - formTitle?: string; + formTitle: string; /** * Label for the email input */ - emailLabel?: string; + emailLabel: string; /** * Placeholder text for Feedback email input */ - emailPlaceholder?: string; + emailPlaceholder: string; /** * Label for the message input */ - messageLabel?: string; + messageLabel: string; /** * Placeholder text for Feedback message input @@ -112,32 +112,32 @@ export interface FeedbackTextConfiguration { /** * Label for the name input */ - nameLabel?: string; + nameLabel: string; /** * Message after feedback was sent successfully */ - successMessageText?: string; + successMessageText: string; /** * Placeholder text for Feedback name input */ - namePlaceholder?: string; + namePlaceholder: string; /** * Text which indicates that a field is required */ - isRequiredLabel?: string; + isRequiredLabel: string; /** * The label for the button that adds a screenshot */ - addScreenshotButtonLabel?: string; + addScreenshotButtonLabel: string; /** * The label for the button that removes a screenshot */ - removeScreenshotButtonLabel?: string; + removeScreenshotButtonLabel: string; /** * The label for the button that shows the capture screenshot button @@ -147,27 +147,27 @@ export interface FeedbackTextConfiguration { /** * The title of the error dialog */ - errorTitle?: string; + errorTitle: string; /** * The error message when the form is invalid */ - formError?: string; + formError: string; /** * The error message when the email is invalid */ - emailError?: string; + emailError: string; /** * The error message when the capture screenshot fails */ - captureScreenshotError?: string; + captureScreenshotError: string; /** * Message when there is a generic error */ - genericError?: string; + genericError: string; } /** @@ -207,34 +207,34 @@ export interface FeedbackCallbacks { /** * Callback when form is opened */ - onFormOpen?: () => void; + onFormOpen: () => void; /** * Callback when form is closed and not submitted */ - onFormClose?: () => void; + onFormClose: () => void; /** * Callback when a screenshot is added */ - onAddScreenshot?: (addScreenshot: (uri: string) => void) => void; + onAddScreenshot: (addScreenshot: (uri: string) => void) => void; /** * Callback when feedback is successfully submitted * * After this you'll see a SuccessMessage on the screen for a moment. */ - onSubmitSuccess?: (data: FeedbackFormData) => void; + onSubmitSuccess: (data: FeedbackFormData) => void; /** * Callback when feedback is unsuccessfully submitted */ - onSubmitError?: (error: Error) => void; + onSubmitError: (error: Error) => void; /** * Callback when the feedback form is submitted successfully, and the SuccessMessage is complete, or dismissed */ - onFormSubmitted?: () => void; + onFormSubmitted: () => void; } /** diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index e554715586..904bdc123a 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -1,5 +1,4 @@ import { logger } from '@sentry/core'; - import { isWeb } from '../utils/environment'; import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy'; @@ -7,6 +6,10 @@ export const PULL_DOWN_CLOSE_THRESHOLD = 200; export const SLIDE_ANIMATION_DURATION = 200; export const BACKGROUND_ANIMATION_DURATION = 200; +const NOOP_SET_VISIBILITY = (): void => { + // No-op +}; + abstract class FeedbackManager { protected static _isVisible = false; protected static _setVisibility: (visible: boolean) => void; @@ -24,11 +27,11 @@ abstract class FeedbackManager { */ public static reset(): void { this._isVisible = false; - this._setVisibility = undefined; + this._setVisibility = NOOP_SET_VISIBILITY; } public static show(): void { - if (this._setVisibility) { + if (this._setVisibility !== NOOP_SET_VISIBILITY) { this._isVisible = true; this._setVisibility(true); } else { @@ -39,7 +42,7 @@ abstract class FeedbackManager { } public static hide(): void { - if (this._setVisibility) { + if (this._setVisibility !== NOOP_SET_VISIBILITY) { this._isVisible = false; this._setVisibility(false); } else { diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx index 9e90ed785f..2e73788ea2 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx @@ -1,14 +1,20 @@ import { logger } from '@sentry/core'; import * as React from 'react'; import { type NativeEventSubscription, type NativeScrollEvent,type NativeSyntheticEvent, Animated, Appearance, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; - import { notWeb } from '../utils/environment'; import { FeedbackButton } from './FeedbackButton'; import { FeedbackWidget } from './FeedbackWidget'; import { modalSheetContainer,modalWrapper, topSpacer } from './FeedbackWidget.styles'; import { getTheme } from './FeedbackWidget.theme'; import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; -import { BACKGROUND_ANIMATION_DURATION,FeedbackButtonManager, FeedbackWidgetManager, PULL_DOWN_CLOSE_THRESHOLD, ScreenshotButtonManager, SLIDE_ANIMATION_DURATION } from './FeedbackWidgetManager'; +import { + BACKGROUND_ANIMATION_DURATION, + FeedbackButtonManager, + FeedbackWidgetManager, + PULL_DOWN_CLOSE_THRESHOLD, + ScreenshotButtonManager, + SLIDE_ANIMATION_DURATION, +} from './FeedbackWidgetManager'; import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration'; import { ScreenshotButton } from './ScreenshotButton'; import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils'; @@ -44,7 +50,7 @@ export class FeedbackWidgetProvider extends React.Component { @@ -120,7 +126,7 @@ export class FeedbackWidgetProvider extends React.Component { logger.info('FeedbackWidgetProvider componentDidUpdate'); }); @@ -154,25 +160,36 @@ export class FeedbackWidgetProvider extends React.Component} {isScreenshotButtonVisible && } - {isVisible && + {isVisible && ( - + + {...this._panResponder.panHandlers} + > - + + onFormSubmitted={this._handleClose} + /> - } + + )} ); } @@ -198,7 +215,7 @@ export class FeedbackWidgetProvider extends React.Component { // Change of the state unmount the component // which would cancel the animation diff --git a/packages/core/src/js/feedback/ScreenshotButton.tsx b/packages/core/src/js/feedback/ScreenshotButton.tsx index 40eae6168f..7f5bffd334 100644 --- a/packages/core/src/js/feedback/ScreenshotButton.tsx +++ b/packages/core/src/js/feedback/ScreenshotButton.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import type { NativeEventSubscription} from 'react-native'; import { Appearance, Image, Text, TouchableOpacity } from 'react-native'; - import type { Screenshot } from '../wrapper'; import { NATIVE } from '../wrapper'; import { defaultScreenshotButtonConfiguration } from './defaults'; @@ -38,7 +37,7 @@ export const getCapturedScreenshot = (): Screenshot | 'ErrorCapturingScreenshot' * Implements a screenshot button that takes a screenshot. */ export class ScreenshotButton extends React.Component { - private _themeListener: NativeEventSubscription; + private _themeListener: NativeEventSubscription | undefined; public constructor(props: ScreenshotButtonProps) { super(props); @@ -78,7 +77,7 @@ export class ScreenshotButton extends React.Component { return ( diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index 2158b69a41..59f2092f9f 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -23,7 +23,7 @@ const CAPTURE_SCREENSHOT_LABEL = 'Take a screenshot'; const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; const GENERIC_ERROR_TEXT = 'Unable to send feedback due to an unexpected error.'; -export const defaultConfiguration: Partial = { +export const defaultConfiguration: FeedbackWidgetProps = { // FeedbackCallbacks onFormOpen: () => { // Does nothing by default diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts index d450422aa3..7182205278 100644 --- a/packages/core/src/js/feedback/integration.ts +++ b/packages/core/src/js/feedback/integration.ts @@ -1,5 +1,4 @@ import { type Integration, getClient } from '@sentry/core'; - import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types'; @@ -15,7 +14,7 @@ type FeedbackIntegration = Integration & { }; export const feedbackIntegration = ( - initOptions: FeedbackWidgetProps & { + initOptions: Partial & { buttonOptions?: FeedbackButtonProps; screenshotButtonOptions?: ScreenshotButtonProps; colorScheme?: 'system' | 'light' | 'dark'; @@ -43,7 +42,7 @@ export const feedbackIntegration = ( }; }; -const _getClientIntegration = (): FeedbackIntegration => { +const _getClientIntegration = (): FeedbackIntegration | undefined => { return getClient()?.getIntegrationByName>(MOBILE_FEEDBACK_INTEGRATION_NAME); }; @@ -76,7 +75,7 @@ export const getScreenshotButtonOptions = (): Partial => export const getColorScheme = (): 'system' | 'light' | 'dark' => { const integration = _getClientIntegration(); - if (!integration) { + if (!integration?.colorScheme) { return 'system'; } diff --git a/packages/core/src/js/feedback/lazy.ts b/packages/core/src/js/feedback/lazy.ts index c3d2b2727d..6bfad02f56 100644 --- a/packages/core/src/js/feedback/lazy.ts +++ b/packages/core/src/js/feedback/lazy.ts @@ -1,5 +1,4 @@ import { getClient } from '@sentry/core'; - import { feedbackIntegration, MOBILE_FEEDBACK_INTEGRATION_NAME } from './integration'; /** diff --git a/packages/core/src/js/feedback/utils.ts b/packages/core/src/js/feedback/utils.ts index 6644bd7468..be839957ae 100644 --- a/packages/core/src/js/feedback/utils.ts +++ b/packages/core/src/js/feedback/utils.ts @@ -1,5 +1,4 @@ import { Alert } from 'react-native'; - import { isFabricEnabled, isWeb } from '../utils/environment'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { ReactNativeLibraries } from './../utils/rnlibraries'; @@ -15,7 +14,7 @@ declare global { */ export function isModalSupported(): boolean { const { major, minor } = ReactNativeLibraries.ReactNativeVersion?.version || {}; - return !(isFabricEnabled() && major === 0 && minor < 71); + return !(isFabricEnabled() && major === 0 && minor && minor < 71); } /** @@ -24,7 +23,7 @@ export function isModalSupported(): boolean { */ export function isNativeDriverSupportedForColorAnimations(): boolean { const { major, minor } = ReactNativeLibraries.ReactNativeVersion?.version || {}; - return major > 0 || minor >= 69; + return (major && major > 0) || (minor && minor >= 69) || false; } export const isValidEmail = (email: string): boolean => { diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 1e5a0f68ad..4a475a33c1 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -1,6 +1,5 @@ export type { Breadcrumb, - Request, SdkInfo, Event, Exception, @@ -46,7 +45,6 @@ export { getClient, setCurrentClient, addEventProcessor, - metricsDefault as metrics, lastEventId, } from '@sentry/core'; @@ -59,13 +57,20 @@ export { withProfiler, } from '@sentry/react'; +export { + logger, + consoleLoggingIntegration, + featureFlagsIntegration, + type FeatureFlagsIntegration, +} from '@sentry/browser'; + export * from './integrations/exports'; export { SDK_NAME, SDK_VERSION } from './version'; export type { ReactNativeOptions } from './options'; export { ReactNativeClient } from './client'; -export { init, wrap, nativeCrash, flush, close, captureUserFeedback, withScope, crashedLastRun } from './sdk'; +export { init, wrap, nativeCrash, flush, close, withScope, crashedLastRun } from './sdk'; export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { diff --git a/packages/core/src/js/integrations/appRegistry.ts b/packages/core/src/js/integrations/appRegistry.ts index 2467d73876..25634e0b25 100644 --- a/packages/core/src/js/integrations/appRegistry.ts +++ b/packages/core/src/js/integrations/appRegistry.ts @@ -1,6 +1,5 @@ import type { Client, Integration } from '@sentry/core'; import { getClient, logger } from '@sentry/core'; - import { isWeb } from '../utils/environment'; import { fillTyped } from '../utils/fill'; import { ReactNativeLibraries } from '../utils/rnlibraries'; diff --git a/packages/core/src/js/integrations/breadcrumbs.ts b/packages/core/src/js/integrations/breadcrumbs.ts new file mode 100644 index 0000000000..da3b57eceb --- /dev/null +++ b/packages/core/src/js/integrations/breadcrumbs.ts @@ -0,0 +1,74 @@ +import { breadcrumbsIntegration as browserBreadcrumbsIntegration } from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { isWeb } from '../utils/environment'; + +interface BreadcrumbsOptions { + /** + * Log calls to console.log, console.debug, and so on. + */ + console: boolean; + + /** + * Log all click and keypress events. + * + * Only available on web. In React Native this is a no-op. + */ + dom: + | boolean + | { + serializeAttribute?: string | string[]; + maxStringLength?: number; + }; + + /** + * Log HTTP requests done with the global Fetch API. + * + * Disabled by default in React Native because fetch is built on XMLHttpRequest. + * Enabled by default on web. + * + * Setting `fetch: true` and `xhr: true` will cause duplicates in React Native. + */ + fetch: boolean; + + /** + * Log calls to history.pushState and related APIs. + * + * Only available on web. In React Native this is a no-op. + */ + history: boolean; + + /** + * Log whenever we send an event to the server. + */ + sentry: boolean; + + /** + * Log HTTP requests done with the XHR API. + * + * Because React Native global fetch is built on XMLHttpRequest, + * this will also log `fetch` network requests. + * + * Setting `fetch: true` and `xhr: true` will cause duplicates in React Native. + */ + xhr: boolean; +} + +export const breadcrumbsIntegration = (options: Partial = {}): Integration => { + const _options: BreadcrumbsOptions = { + // FIXME: In mobile environment XHR is implemented by native APIs, which are instrumented by the Native SDK. + // This will cause duplicates in React Native. On iOS `NSURLSession` is instrumented by default. On Android + // `OkHttp` is only instrumented by SAGP. + xhr: true, + console: true, + sentry: true, + ...options, + fetch: options.fetch ?? (isWeb() ? true : false), + dom: isWeb() ? options.dom ?? true : false, + history: isWeb() ? options.history ?? true : false, + }; + + // Historically we had very little issue using the browser breadcrumbs integration + // and thus we don't cherry pick the implementation like for example the Sentry Deno SDK does. + // https://github.com/getsentry/sentry-javascript/blob/d007407c2e51d93d6d3933f9dea1e03ff3f4a4ab/packages/deno/src/integrations/breadcrumbs.ts#L34 + return browserBreadcrumbsIntegration(_options); +}; diff --git a/packages/core/src/js/integrations/debugsymbolicator.ts b/packages/core/src/js/integrations/debugsymbolicator.ts index 8529d0eeb6..f17ec90d55 100644 --- a/packages/core/src/js/integrations/debugsymbolicator.ts +++ b/packages/core/src/js/integrations/debugsymbolicator.ts @@ -1,6 +1,5 @@ import type { Event, EventHint, Exception, Integration, StackFrame as SentryStackFrame } from '@sentry/core'; import { logger } from '@sentry/core'; - import type { ExtendedError } from '../utils/error'; import { getFramesToPop, isErrorLike } from '../utils/error'; import type * as ReactNative from '../vendor/react-native'; @@ -131,7 +130,7 @@ async function convertReactNativeFramesToSentryFrames(frames: ReactNative.StackF * @param event Event * @param frames StackFrame[] */ -function replaceExceptionFramesInException(exception: Exception, frames: SentryStackFrame[]): void { +function replaceExceptionFramesInException(exception: Exception | undefined, frames: SentryStackFrame[]): void { if (exception?.stacktrace) { exception.stacktrace.frames = frames.reverse(); } @@ -143,7 +142,7 @@ function replaceExceptionFramesInException(exception: Exception, frames: SentryS * @param frames StackFrame[] */ function replaceThreadFramesInEvent(event: Event, frames: SentryStackFrame[]): void { - if (event.threads && event.threads.values && event.threads.values[0] && event.threads.values[0].stacktrace) { + if (event.threads?.values?.[0]?.stacktrace) { event.threads.values[0].stacktrace.frames = frames.reverse(); } } diff --git a/packages/core/src/js/integrations/debugsymbolicatorutils.ts b/packages/core/src/js/integrations/debugsymbolicatorutils.ts index 2b51171b39..8f20790c09 100644 --- a/packages/core/src/js/integrations/debugsymbolicatorutils.ts +++ b/packages/core/src/js/integrations/debugsymbolicatorutils.ts @@ -1,6 +1,5 @@ import type { StackFrame as SentryStackFrame } from '@sentry/core'; import { logger } from '@sentry/core'; - import { ReactNativeLibraries } from '../utils/rnlibraries'; import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr'; import type * as ReactNative from '../vendor/react-native'; @@ -20,7 +19,14 @@ export async function fetchSourceContext(frames: SentryStackFrame[]): Promise { + return { + name: INTEGRATION_NAME, + setup(client: ReactNativeClient) { + client.on('afterInit', () => { + cacheLogContext().then( + () => { + client.on('beforeCaptureLog', (log: Log) => { + processLog(log); + }); + }, + reason => { + logger.log(reason); + }, + ); + }); + }, + }; +}; + +let NativeCache: Record | undefined = undefined; + +async function cacheLogContext(): Promise { + try { + const response = await NATIVE.fetchNativeLogAttributes(); + + NativeCache = { + ...(response?.contexts?.device && { + brand: response.contexts.device?.brand, + model: response.contexts.device?.model, + family: response.contexts.device?.family, + }), + ...(response?.contexts?.os && { + os: response.contexts.os.name, + version: response.contexts.os.version, + }), + ...(response?.contexts?.release && { + release: response.contexts.release, + }), + }; + } catch (e) { + return Promise.reject(`[LOGS]: Failed to prepare attributes from Native Layer: ${e}`); + } + return Promise.resolve(); +} + +function processLog(log: Log): void { + if (NativeCache === undefined) { + return; + } + + log.attributes = log.attributes ?? {}; + NativeCache.brand && (log.attributes['device.brand'] = NativeCache.brand); + NativeCache.model && (log.attributes['device.model'] = NativeCache.model); + NativeCache.family && (log.attributes['device.family'] = NativeCache.family); + NativeCache.os && (log.attributes['os.name'] = NativeCache.os); + NativeCache.version && (log.attributes['os.version'] = NativeCache.version); + NativeCache.release && (log.attributes['sentry.release'] = NativeCache.release); +} diff --git a/packages/core/src/js/integrations/modulesloader.ts b/packages/core/src/js/integrations/modulesloader.ts index 7a31154d33..982c8a1519 100644 --- a/packages/core/src/js/integrations/modulesloader.ts +++ b/packages/core/src/js/integrations/modulesloader.ts @@ -1,6 +1,5 @@ import type { Event, Integration } from '@sentry/core'; import { logger } from '@sentry/core'; - import { NATIVE } from '../wrapper'; const INTEGRATION_NAME = 'ModulesLoader'; diff --git a/packages/core/src/js/integrations/nativelinkederrors.ts b/packages/core/src/js/integrations/nativelinkederrors.ts index 39d8d55879..727ef85638 100644 --- a/packages/core/src/js/integrations/nativelinkederrors.ts +++ b/packages/core/src/js/integrations/nativelinkederrors.ts @@ -11,7 +11,6 @@ import type { StackParser, } from '@sentry/core'; import { isInstanceOf, isPlainObject, isString } from '@sentry/core'; - import type { NativeStackFrames } from '../NativeRNSentry'; import { NATIVE } from '../wrapper'; @@ -43,7 +42,7 @@ export const nativeLinkedErrorsIntegration = (options: Partial True, false -> False. + * Symbols are stringified. + * + */ +export const primitiveTagIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + setup(client) { + client.on('beforeSendEvent', event => { + if (event.tags) { + Object.keys(event.tags).forEach(key => { + event.tags![key] = PrimitiveToString(event.tags![key]); + }); + } + }); + }, + afterAllSetup() { + if (NATIVE.enableNative) { + NATIVE._setPrimitiveProcessor((value: Primitive) => PrimitiveToString(value)); + } + }, + }; +}; diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index df56da47cb..4d91a237ac 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -7,7 +7,7 @@ import { getCurrentScope, logger, } from '@sentry/core'; - +import type { ReactNativeClientOptions } from '../options'; import { isHermesEnabled, isWeb } from '../utils/environment'; import { createSyntheticError, isErrorLike } from '../utils/error'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; @@ -141,7 +141,7 @@ function setupErrorUtilsGlobalHandler(): void { return; } - const defaultHandler = errorUtils.getGlobalHandler && errorUtils.getGlobalHandler(); + const defaultHandler = errorUtils.getGlobalHandler?.(); // eslint-disable-next-line @typescript-eslint/no-explicit-any errorUtils.setGlobalHandler(async (error: any, isFatal?: boolean) => { @@ -197,7 +197,7 @@ function setupErrorUtilsGlobalHandler(): void { return; } - void client.flush(client.getOptions().shutdownTimeout || 2000).then( + void client.flush((client.getOptions() as ReactNativeClientOptions).shutdownTimeout || 2000).then( () => { defaultHandler(error, isFatal); }, diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlersutils.ts b/packages/core/src/js/integrations/reactnativeerrorhandlersutils.ts index 7453f696d6..c7fc9690ab 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlersutils.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlersutils.ts @@ -1,5 +1,4 @@ import { logger } from '@sentry/core'; - import { ReactNativeLibraries } from '../utils/rnlibraries'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; diff --git a/packages/core/src/js/integrations/reactnativeinfo.ts b/packages/core/src/js/integrations/reactnativeinfo.ts index b24e20f917..f45139f232 100644 --- a/packages/core/src/js/integrations/reactnativeinfo.ts +++ b/packages/core/src/js/integrations/reactnativeinfo.ts @@ -1,5 +1,4 @@ import type { Context, Event, EventHint, Integration } from '@sentry/core'; - import { getExpoGoVersion, getExpoSdkVersion, @@ -61,7 +60,7 @@ function processEvent(event: Event, hint: EventHint): Event { if (reactNativeContext.js_engine === 'hermes') { event.tags = { - hermes: 'true', + hermes: true, ...event.tags, }; } diff --git a/packages/core/src/js/integrations/release.ts b/packages/core/src/js/integrations/release.ts index f414f8a9ac..3682bb42b3 100644 --- a/packages/core/src/js/integrations/release.ts +++ b/packages/core/src/js/integrations/release.ts @@ -1,5 +1,4 @@ import type { BaseTransportOptions, Client, ClientOptions, Event, EventHint, Integration } from '@sentry/core'; - import { NATIVE } from '../wrapper'; const INTEGRATION_NAME = 'Release'; diff --git a/packages/core/src/js/integrations/rewriteframes.ts b/packages/core/src/js/integrations/rewriteframes.ts index 09ee8b6398..81c28a3bf9 100644 --- a/packages/core/src/js/integrations/rewriteframes.ts +++ b/packages/core/src/js/integrations/rewriteframes.ts @@ -1,7 +1,6 @@ import type { Integration, StackFrame } from '@sentry/core'; import { rewriteFramesIntegration } from '@sentry/core'; import { Platform } from 'react-native'; - import { isExpo, isHermesEnabled } from '../utils/environment'; export const ANDROID_DEFAULT_BUNDLE_NAME = 'app:///index.android.bundle'; diff --git a/packages/core/src/js/integrations/screenshot.ts b/packages/core/src/js/integrations/screenshot.ts index 6f504fa76e..3c45ada451 100644 --- a/packages/core/src/js/integrations/screenshot.ts +++ b/packages/core/src/js/integrations/screenshot.ts @@ -1,5 +1,4 @@ import type { Event, EventHint, Integration } from '@sentry/core'; - import type { ReactNativeClient } from '../client'; import type { Screenshot as ScreenshotAttachment } from '../wrapper'; import { NATIVE } from '../wrapper'; @@ -18,7 +17,7 @@ export const screenshotIntegration = (): Integration => { }; async function processEvent(event: Event, hint: EventHint, client: ReactNativeClient): Promise { - const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + const hasException = event.exception?.values && event.exception.values.length > 0; if (!hasException || client.getOptions().beforeScreenshot?.(event, hint) === false) { return event; } diff --git a/packages/core/src/js/integrations/sdkinfo.ts b/packages/core/src/js/integrations/sdkinfo.ts index b90614d5c3..9a5ba222f0 100644 --- a/packages/core/src/js/integrations/sdkinfo.ts +++ b/packages/core/src/js/integrations/sdkinfo.ts @@ -1,6 +1,5 @@ import type { Event, Integration, Package, SdkInfo as SdkInfoType } from '@sentry/core'; import { logger } from '@sentry/core'; - import { isExpoGo, notWeb } from '../utils/environment'; import { SDK_NAME, SDK_PACKAGE_NAME, SDK_VERSION } from '../version'; import { NATIVE } from '../wrapper'; diff --git a/packages/core/src/js/integrations/spotlight.ts b/packages/core/src/js/integrations/spotlight.ts index b4f62e06da..44497bdec9 100644 --- a/packages/core/src/js/integrations/spotlight.ts +++ b/packages/core/src/js/integrations/spotlight.ts @@ -1,6 +1,5 @@ import type { BaseTransportOptions, Client, ClientOptions, Envelope, Integration } from '@sentry/core'; import { logger, serializeEnvelope } from '@sentry/core'; - import { ReactNativeLibraries } from '../utils/rnlibraries'; import { createStealthXhr, XHR_READYSTATE_DONE } from '../utils/xhr'; @@ -83,17 +82,23 @@ function sendEnvelopesToSidecar(client: Client, sidecarUrl: string): void { }); } +const DEFAULT_SIDECAR_URL = 'http://localhost:8969/stream'; + /** * Gets the default Spotlight sidecar URL. */ export function getDefaultSidecarUrl(): string { try { - const { url } = ReactNativeLibraries.Devtools?.getDevServer(); + const { url } = ReactNativeLibraries.Devtools?.getDevServer() ?? {}; + if (!url) { + return DEFAULT_SIDECAR_URL; + } + return `http://${getHostnameFromString(url)}:8969/stream`; } catch (_oO) { // We can't load devserver URL } - return 'http://localhost:8969/stream'; + return DEFAULT_SIDECAR_URL; } /** @@ -103,7 +108,7 @@ function getHostnameFromString(urlString: string): string | null { const regex = /^(?:\w+:)?\/\/([^/:]+)(:\d+)?(.*)$/; const matches = urlString.match(regex); - if (matches && matches[1]) { + if (matches?.[1]) { return matches[1]; } else { // Invalid URL format diff --git a/packages/core/src/js/integrations/viewhierarchy.ts b/packages/core/src/js/integrations/viewhierarchy.ts index 9b84ece273..eadb618510 100644 --- a/packages/core/src/js/integrations/viewhierarchy.ts +++ b/packages/core/src/js/integrations/viewhierarchy.ts @@ -1,6 +1,5 @@ import type { Attachment, Event, EventHint, Integration } from '@sentry/core'; import { logger } from '@sentry/core'; - import { NATIVE } from '../wrapper'; const filename: string = 'view-hierarchy.json'; @@ -21,7 +20,7 @@ export const viewHierarchyIntegration = (): Integration => { }; async function processEvent(event: Event, hint: EventHint): Promise { - const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + const hasException = event.exception?.values && event.exception.values.length > 0; if (!hasException) { return event; } diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index 1890fd36e9..028abba45c 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -1,15 +1,17 @@ import type { makeFetchTransport } from '@sentry/browser'; import type { CaptureContext, ClientOptions, Event, EventHint, Options } from '@sentry/core'; -import type { Profiler } from '@sentry/react'; +import type { BrowserOptions, Profiler } from '@sentry/react'; import type * as React from 'react'; import { Platform } from 'react-native'; - import type { TouchEventBoundaryProps } from './touchevents'; -import { getExpoConstants } from './utils/expomodules'; +import { isExpoGo } from './utils/environment'; type ProfilerProps = React.ComponentProps; type BrowserTransportOptions = Parameters[0]; +type BrowserExperiments = NonNullable; +type SharedExperimentsSubset = Pick; + export interface BaseReactNativeOptions { /** * Enables native transport + device info + offline caching. @@ -234,6 +236,14 @@ export interface BaseReactNativeOptions { */ replaysOnErrorSampleRate?: number; + /** + * Controls how many milliseconds to wait before shutting down. The default is 2 seconds. Setting this too low can cause + * problems for sending events from command line applications. Setting it too + * high can cause the application to block for users with network connectivity + * problems. + */ + shutdownTimeout?: number; + /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay * will be, but also more data to transfer and more CPU load. @@ -245,7 +255,7 @@ export interface BaseReactNativeOptions { /** * Options which are in beta, or otherwise not guaranteed to be stable. */ - _experiments?: { + _experiments?: SharedExperimentsSubset & { [key: string]: unknown; /** @@ -307,7 +317,7 @@ export interface ReactNativeClientOptions export interface ReactNativeWrapperOptions { /** Props for the root React profiler */ - profilerProps?: ProfilerProps; + profilerProps?: Omit; /** Props for the root touch event boundary */ touchEventBoundaryProps?: TouchEventBoundaryProps; @@ -329,8 +339,7 @@ export function shouldEnableNativeNagger(userOptions: unknown): boolean { return false; } - const expoConstants = getExpoConstants(); - if (expoConstants && expoConstants.appOwnership === 'expo') { + if (isExpoGo()) { // If the app is running in Expo Go, we don't want to nag return false; } diff --git a/packages/core/src/js/playground/examples.ts b/packages/core/src/js/playground/examples.ts index b06c4078c0..f82ddca83c 100644 --- a/packages/core/src/js/playground/examples.ts +++ b/packages/core/src/js/playground/examples.ts @@ -1,5 +1,4 @@ import { captureException } from '@sentry/core'; - import { NATIVE } from '../wrapper'; // This is a placeholder to match the example code with what Sentry SDK users would see. diff --git a/packages/core/src/js/playground/modal.tsx b/packages/core/src/js/playground/modal.tsx index 08d920aefe..847ce06883 100644 --- a/packages/core/src/js/playground/modal.tsx +++ b/packages/core/src/js/playground/modal.tsx @@ -13,7 +13,6 @@ import { useColorScheme, View, } from 'react-native'; - import { getDevServer } from '../integrations/debugsymbolicatorutils'; import { isExpo, isExpoGo, isWeb } from '../utils/environment'; import { bug as bugAnimation, hi as hiAnimation, thumbsup as thumbsupAnimation } from './animations'; @@ -52,7 +51,7 @@ export const withSentryPlayground =

( ); }; - Wrapper.displayName = `withSentryPlayground()`; + Wrapper.displayName = 'withSentryPlayground()'; return Wrapper; }; @@ -191,7 +190,7 @@ export const SentryPlayground = ({ ); }; -const Animation = ({ id }: { id: string }): React.ReactElement => { +const Animation = ({ id }: { id: string }): React.ReactElement | null => { const shouldFallbackToImage = Platform.OS === 'android'; switch (id) { @@ -219,7 +218,7 @@ const Row = ({ last = false, // eslint-disable-next-line @typescript-eslint/no-empty-function action = () => {}, - actionDescription, + actionDescription = '', title, description, disabled = false, @@ -423,12 +422,17 @@ const lightStyles: typeof defaultDarkStyles = StyleSheet.create({ }); function openURLInBrowser(url: string): void { - // This doesn't work for Expo project with Web enabled - // disable-next-line @typescript-eslint/no-floating-promises - fetch(`${getDevServer().url}open-url`, { - method: 'POST', - body: JSON.stringify({ url }), - }).catch(e => { - logger.error('Error opening URL:', e); - }); + const devServer = getDevServer(); + if (devServer?.url) { + // This doesn't work for Expo project with Web enabled + // disable-next-line @typescript-eslint/no-floating-promises + fetch(`${devServer.url}open-url`, { + method: 'POST', + body: JSON.stringify({ url }), + }).catch(e => { + logger.error('Error opening URL:', e); + }); + } else { + logger.error('Dev server URL not available'); + } } diff --git a/packages/core/src/js/profiling/convertHermesProfile.ts b/packages/core/src/js/profiling/convertHermesProfile.ts index 39ed9ac752..c84cee963e 100644 --- a/packages/core/src/js/profiling/convertHermesProfile.ts +++ b/packages/core/src/js/profiling/convertHermesProfile.ts @@ -1,6 +1,5 @@ import type { FrameId, StackId, ThreadCpuFrame, ThreadCpuSample, ThreadCpuStack, ThreadId } from '@sentry/core'; import { logger } from '@sentry/core'; - import { MAX_PROFILE_DURATION_MS } from './constants'; import type * as Hermes from './hermes'; import { DEFAULT_BUNDLE_NAME } from './hermes'; @@ -82,11 +81,21 @@ export function mapSamples( hermesStacks: Set; jsThreads: Set; } { + const samples: ThreadCpuSample[] = []; const jsThreads = new Set(); const hermesStacks = new Set(); - const start = Number(hermesSamples[0].ts); - const samples: ThreadCpuSample[] = []; + const firstSample = hermesSamples[0]; + if (!firstSample) { + logger.warn('[Profiling] No samples found in profile.'); + return { + samples, + hermesStacks, + jsThreads, + }; + } + + const start = Number(firstSample.ts); for (const hermesSample of hermesSamples) { jsThreads.add(hermesSample.tid); hermesStacks.add(hermesSample.sf); @@ -130,8 +139,12 @@ function mapFrames(hermesStackFrames: Record(); - const options = client && client.getOptions(); + const options = client?.getOptions?.(); const profilesSampleRate = options && typeof options.profilesSampleRate === 'number' ? options.profilesSampleRate : undefined; diff --git a/packages/core/src/js/profiling/types.ts b/packages/core/src/js/profiling/types.ts index 871c975403..e483d4ccfc 100644 --- a/packages/core/src/js/profiling/types.ts +++ b/packages/core/src/js/profiling/types.ts @@ -1,5 +1,4 @@ import type { DebugImage, MeasurementUnit, Profile, ThreadCpuFrame, ThreadCpuProfile } from '@sentry/core'; - import type { NativeProfileEvent } from './nativeTypes'; export interface RawThreadCpuProfile extends ThreadCpuProfile { diff --git a/packages/core/src/js/profiling/utils.ts b/packages/core/src/js/profiling/utils.ts index c83342b50b..ebdc57e816 100644 --- a/packages/core/src/js/profiling/utils.ts +++ b/packages/core/src/js/profiling/utils.ts @@ -1,7 +1,6 @@ /* eslint-disable complexity */ import type { Envelope, Event, ThreadCpuProfile } from '@sentry/core'; import { forEachEnvelopeItem, logger } from '@sentry/core'; - import { getDefaultEnvironment } from '../utils/environment'; import { getDebugMetadata } from './debugid'; import type { @@ -75,12 +74,12 @@ export function enrichCombinedProfileWithEventContext( return null; } - const trace_id = (event.contexts && event.contexts.trace && event.contexts.trace.trace_id) || ''; + const trace_id = event.contexts?.trace?.trace_id || ''; // Log a warning if the profile has an invalid traceId (should be uuidv4). // All profiles and transactions are rejected if this is the case and we want to // warn users that this is happening if they enable debug flag - if (trace_id && trace_id.length !== 32) { + if (trace_id?.length !== 32) { if (__DEV__) { logger.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); } @@ -97,25 +96,25 @@ export function enrichCombinedProfileWithEventContext( release: event.release || '', environment: event.environment || getDefaultEnvironment(), os: { - name: (event.contexts && event.contexts.os && event.contexts.os.name) || '', - version: (event.contexts && event.contexts.os && event.contexts.os.version) || '', - build_number: (event.contexts && event.contexts.os && event.contexts.os.build) || '', + name: event.contexts?.os?.name || '', + version: event.contexts?.os?.version || '', + build_number: event.contexts?.os?.build || '', }, device: { - locale: (event.contexts && event.contexts.device && (event.contexts.device.locale as string)) || '', - model: (event.contexts && event.contexts.device && event.contexts.device.model) || '', - manufacturer: (event.contexts && event.contexts.device && event.contexts.device.manufacturer) || '', - architecture: (event.contexts && event.contexts.device && event.contexts.device.arch) || '', - is_emulator: (event.contexts && event.contexts.device && event.contexts.device.simulator) || false, + locale: (event.contexts?.device && (event.contexts.device.locale as string)) || '', + model: event.contexts?.device?.model || '', + manufacturer: event.contexts?.device?.manufacturer || '', + architecture: event.contexts?.device?.arch || '', + is_emulator: event.contexts?.device?.simulator || false, }, transaction: { name: event.transaction || '', id: event.event_id || '', trace_id, - active_thread_id: (profile.transaction && profile.transaction.active_thread_id) || '', + active_thread_id: profile.transaction?.active_thread_id || '', }, debug_meta: { - images: [...getDebugMetadata(), ...((profile.debug_meta && profile.debug_meta.images) || [])], + images: [...getDebugMetadata(), ...(profile.debug_meta?.images || [])], }, }; } @@ -136,19 +135,15 @@ export function enrichAndroidProfileWithEventContext( build_id: profile.build_id || '', device_cpu_frequencies: [], - device_is_emulator: (event.contexts && event.contexts.device && event.contexts.device.simulator) || false, - device_locale: (event.contexts && event.contexts.device && (event.contexts.device.locale as string)) || '', - device_manufacturer: (event.contexts && event.contexts.device && event.contexts.device.manufacturer) || '', - device_model: (event.contexts && event.contexts.device && event.contexts.device.model) || '', - device_os_name: (event.contexts && event.contexts.os && event.contexts.os.name) || '', - device_os_version: (event.contexts && event.contexts.os && event.contexts.os.version) || '', + device_is_emulator: event.contexts?.device?.simulator || false, + device_locale: (event.contexts?.device && (event.contexts.device.locale as string)) || '', + device_manufacturer: event.contexts?.device?.manufacturer || '', + device_model: event.contexts?.device?.model || '', + device_os_name: event.contexts?.os?.name || '', + device_os_version: event.contexts?.os?.version || '', device_physical_memory_bytes: - (event.contexts && - event.contexts.device && - event.contexts.device.memory_size && - Number(event.contexts.device.memory_size).toString(10)) || - '', + (event.contexts?.device?.memory_size && Number(event.contexts.device.memory_size).toString(10)) || '', environment: event.environment || getDefaultEnvironment(), @@ -161,7 +156,7 @@ export function enrichAndroidProfileWithEventContext( transaction_id: event.event_id || '', transaction_name: event.transaction || '', - trace_id: (event.contexts && event.contexts.trace && event.contexts.trace.trace_id) || '', + trace_id: event.contexts?.trace?.trace_id || '', version_name: event.release || '', version_code: event.dist || '', diff --git a/packages/core/src/js/replay/CustomMask.tsx b/packages/core/src/js/replay/CustomMask.tsx index 4608dfbe04..d3fae8382d 100644 --- a/packages/core/src/js/replay/CustomMask.tsx +++ b/packages/core/src/js/replay/CustomMask.tsx @@ -2,7 +2,6 @@ import { logger } from '@sentry/core'; import * as React from 'react'; import type { HostComponent, ViewProps } from 'react-native'; import { UIManager, View } from 'react-native'; - import { isExpoGo } from '../utils/environment'; const NativeComponentRegistry: { @@ -34,7 +33,7 @@ const UnmaskFallback = (viewProps: ViewProps): React.ReactElement => { return ; }; -const hasViewManagerConfig = (nativeComponentName: string): boolean => UIManager.hasViewManagerConfig && UIManager.hasViewManagerConfig(nativeComponentName); +const hasViewManagerConfig = (nativeComponentName: string): boolean => UIManager.hasViewManagerConfig?.(nativeComponentName); const Mask = ((): HostComponent | React.ComponentType => { if (isExpoGo() || !hasViewManagerConfig(MaskNativeComponentName)) { diff --git a/packages/core/src/js/replay/browserReplay.ts b/packages/core/src/js/replay/browserReplay.ts index b72c0be69f..f316e42d57 100644 --- a/packages/core/src/js/replay/browserReplay.ts +++ b/packages/core/src/js/replay/browserReplay.ts @@ -1,8 +1,29 @@ import { replayIntegration } from '@sentry/react'; +import { notWeb } from '../utils/environment'; +import type { Replay } from './replayInterface'; + +/** + * ReplayConfiguration for browser replay integration. + * + * See the [Configuration documentation](https://docs.sentry.io/platforms/javascript/session-replay/configuration/) for more information. + */ +type ReplayConfiguration = Parameters[0]; + +// https://github.com/getsentry/sentry-javascript/blob/e00cb04f1bbf494067cd8475d392266ba296987a/packages/replay-internal/src/integration.ts#L109 +const INTEGRATION_NAME = 'Replay'; + +/** + * Browser Replay integration for React Native. + * + * See the [Browser Replay documentation](https://docs.sentry.io/platforms/javascript/session-replay/) for more information. + */ +const browserReplayIntegration = (options: ReplayConfiguration = {}): Replay => { + if (notWeb()) { + // This is required because `replayIntegration` browser check doesn't + // work for React Native. + return browserReplayIntegrationNoop(); + } -const browserReplayIntegration = ( - options: Parameters[0] = {}, -): ReturnType => { return replayIntegration({ ...options, mask: ['.sentry-react-native-mask', ...(options.mask || [])], @@ -10,4 +31,18 @@ const browserReplayIntegration = ( }); }; +const browserReplayIntegrationNoop = (): Replay => { + return { + name: INTEGRATION_NAME, + // eslint-disable-next-line @typescript-eslint/no-empty-function + start: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + startBuffering: () => {}, + stop: () => Promise.resolve(), + flush: () => Promise.resolve(), + getReplayId: () => undefined, + getRecordingMode: () => undefined, + }; +}; + export { browserReplayIntegration }; diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 79aa1117ec..75a530cc97 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -1,6 +1,5 @@ import type { Client, DynamicSamplingContext, Event, Integration } from '@sentry/core'; import { logger } from '@sentry/core'; - import { isHardCrash } from '../misc'; import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; @@ -131,7 +130,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau const options = mergeOptions(initOptions); async function processEvent(event: Event): Promise { - const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + const hasException = event.exception?.values && event.exception.values.length > 0; if (!hasException) { // Event is not an error, will not capture replay return event; @@ -178,9 +177,6 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // https://github.com/getsentry/sentry-javascript/blob/develop/packages/replay-internal/src/integration.ts#L45 return { name: MOBILE_REPLAY_INTEGRATION_NAME, - setupOnce() { - /* Noop */ - }, setup, processEvent, options: options, @@ -190,9 +186,6 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau const mobileReplayIntegrationNoop = (): MobileReplayIntegration => { return { name: MOBILE_REPLAY_INTEGRATION_NAME, - setupOnce() { - /* Noop */ - }, options: defaultOptions, }; }; diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 6834294b33..68493f25d0 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -1,5 +1,4 @@ -import { RN_GLOBAL_OBJ } from '../utils/worldwide'; -import { utf8ToBytes } from '../vendor'; +import { encodeUTF8 } from '../utils/encode'; /** Convert a Content-Length header to number/undefined. */ export function parseContentLengthHeader(header: string | null | undefined): number | undefined { @@ -21,16 +20,16 @@ export function getBodySize(body: RequestBody): number | undefined { try { if (typeof body === 'string') { - return _encode(body).length; + return encodeUTF8(body).length; } if (body instanceof URLSearchParams) { - return _encode(body.toString()).length; + return encodeUTF8(body.toString()).length; } if (body instanceof FormData) { const formDataStr = _serializeFormData(body); - return _encode(formDataStr).length; + return encodeUTF8(formDataStr).length; } if (body instanceof Blob) { @@ -49,13 +48,6 @@ export function getBodySize(body: RequestBody): number | undefined { return undefined; } -function _encode(input: string): number[] | Uint8Array { - if (RN_GLOBAL_OBJ.TextEncoder) { - return new RN_GLOBAL_OBJ.TextEncoder().encode(input); - } - return utf8ToBytes(input); -} - function _serializeFormData(formData: FormData): string { // This is a bit simplified, but gives us a decent estimate // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' diff --git a/packages/core/src/js/replay/replayInterface.ts b/packages/core/src/js/replay/replayInterface.ts new file mode 100644 index 0000000000..0308a5a385 --- /dev/null +++ b/packages/core/src/js/replay/replayInterface.ts @@ -0,0 +1,57 @@ +import type { Integration, ReplayRecordingMode } from '@sentry/core'; + +// Based on Replay Class https://github.com/getsentry/sentry-javascript/blob/e00cb04f1bbf494067cd8475d392266ba296987a/packages/replay-internal/src/integration.ts#L50 + +/** + * Common interface for React Native Replay integrations. + * + * Both browser and mobile replay integrations should implement this interface + * to allow user manually control the replay. + */ +export interface Replay extends Integration { + /** + * Start a replay regardless of sampling rate. Calling this will always + * create a new session. Will log a message if replay is already in progress. + * + * Creates or loads a session, attaches listeners to varying events (DOM, + * PerformanceObserver, Recording, Sentry SDK, etc) + */ + start(): void; + + /** + * Start replay buffering. Buffers until `flush()` is called or, if + * `replaysOnErrorSampleRate` > 0, until an error occurs. + */ + startBuffering(): void; + + /** + * Currently, this needs to be manually called (e.g. for tests). Sentry SDK + * does not support a teardown + */ + stop(): Promise; + + /** + * If not in "session" recording mode, flush event buffer which will create a new replay. + * If replay is not enabled, a new session replay is started. + * Unless `continueRecording` is false, the replay will continue to record and + * behave as a "session"-based replay. + * + * Otherwise, queue up a flush. + */ + flush(options?: { continueRecording?: boolean }): Promise; + + /** + * Get the current session ID. + */ + getReplayId(): string | undefined; + + /** + * Get the current recording mode. This can be either `session` or `buffer`. + * + * `session`: Recording the whole session, sending it continuously + * `buffer`: Always keeping the last 60s of recording, requires: + * - having replaysOnErrorSampleRate > 0 to capture replay when an error occurs + * - or calling `flush()` to send the replay + */ + getRecordingMode(): ReplayRecordingMode | undefined; +} diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 8118296ee5..deb0f3c88c 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -1,6 +1,5 @@ import type { Breadcrumb, BreadcrumbHint, SentryWrappedXMLHttpRequest, XhrBreadcrumbHint } from '@sentry/core'; import { dropUndefinedKeys } from '@sentry/core'; - import type { RequestBody } from './networkUtils'; import { getBodySize, parseContentLengthHeader } from './networkUtils'; diff --git a/packages/core/src/js/scopeSync.ts b/packages/core/src/js/scopeSync.ts index bc9f20c597..5c6537d89e 100644 --- a/packages/core/src/js/scopeSync.ts +++ b/packages/core/src/js/scopeSync.ts @@ -1,5 +1,5 @@ import type { Breadcrumb, Scope } from '@sentry/core'; - +import { logger } from '@sentry/react'; import { DEFAULT_BREADCRUMB_LEVEL } from './breadcrumb'; import { fillTyped } from './utils/fill'; import { convertToNormalizedObject } from './utils/normalize'; @@ -26,14 +26,14 @@ export function enableSyncToNative(scope: Scope): void { }); fillTyped(scope, 'setTag', original => (key, value): Scope => { - NATIVE.setTag(key, value as string); + NATIVE.setTag(key, NATIVE.primitiveProcessor(value)); return original.call(scope, key, value); }); fillTyped(scope, 'setTags', original => (tags): Scope => { // As native only has setTag, we just loop through each tag key. Object.keys(tags).forEach(key => { - NATIVE.setTag(key, tags[key] as string); + NATIVE.setTag(key, NATIVE.primitiveProcessor(tags[key])); }); return original.call(scope, tags); }); @@ -60,7 +60,11 @@ export function enableSyncToNative(scope: Scope): void { original.call(scope, mergedBreadcrumb, maxBreadcrumbs); const finalBreadcrumb = scope.getLastBreadcrumb(); - NATIVE.addBreadcrumb(finalBreadcrumb); + if (finalBreadcrumb) { + NATIVE.addBreadcrumb(finalBreadcrumb); + } else { + logger.warn('[ScopeSync] Last created breadcrumb is undefined. Skipping sync to native.'); + } return scope; }); diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 7d085a6725..e1e35a1419 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -1,12 +1,18 @@ /* eslint-disable complexity */ -import type { Breadcrumb, BreadcrumbHint, Integration, Scope, SendFeedbackParams, UserFeedback } from '@sentry/core'; -import { captureFeedback, getClient, getGlobalScope, getIntegrationsToSetup, getIsolationScope, initAndBind, logger, makeDsn, stackParserFromStackParserOptions, withScope as coreWithScope } from '@sentry/core'; +import type { Breadcrumb, BreadcrumbHint, Integration, Scope } from '@sentry/core'; import { - defaultStackParser, - makeFetchTransport, -} from '@sentry/react'; + getClient, + getGlobalScope, + getIntegrationsToSetup, + getIsolationScope, + initAndBind, + logger, + makeDsn, + stackParserFromStackParserOptions, + withScope as coreWithScope, +} from '@sentry/core'; +import { defaultStackParser, makeFetchTransport, Profiler } from '@sentry/react'; import * as React from 'react'; - import { ReactNativeClient } from './client'; import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetProvider'; import { getDevServer } from './integrations/debugsymbolicatorutils'; @@ -18,7 +24,8 @@ import { TouchEventBoundary } from './touchevents'; import { ReactNativeProfiler } from './tracing'; import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; -import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer } from './utils/environment'; +import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer, isWeb } from './utils/environment'; +import { getDefaultRelease } from './utils/release'; import { safeFactory, safeTracesSampler } from './utils/safe'; import { NATIVE } from './wrapper'; @@ -63,7 +70,7 @@ export function init(passedOptions: ReactNativeOptions): void { enableSyncToNative(getIsolationScope()); } - const getURLFromDSN = (dsn: string | null): string | undefined => { + const getURLFromDSN = (dsn: string | undefined): string | undefined => { if (!dsn) { return undefined; } @@ -105,6 +112,7 @@ export function init(passedOptions: ReactNativeOptions): void { const options: ReactNativeClientOptions = { ...DEFAULT_OPTIONS, ...passedOptions, + release: passedOptions.release ?? getDefaultRelease(), enableNative, enableNativeNagger: shouldEnableNativeNagger(passedOptions.enableNativeNagger), // If custom transport factory fails the SDK won't initialize @@ -156,18 +164,21 @@ export function wrap

>( options?: ReactNativeWrapperOptions ): React.ComponentType

{ const profilerProps = { - ...(options?.profilerProps ?? {}), + ...(options?.profilerProps), name: RootComponent.displayName ?? 'Root', + updateProps: {} }; - const RootApp: React.FC

= (appProps) => { + const ProfilerComponent = isWeb() ? Profiler : ReactNativeProfiler; + + const RootApp: React.FC

= appProps => { return ( - + - + ); }; @@ -219,20 +230,6 @@ export async function close(): Promise { } } -/** - * Captures user feedback and sends it to Sentry. - * @deprecated Use `Sentry.captureFeedback` instead. - */ -export function captureUserFeedback(feedback: UserFeedback): void { - const feedbackParams: SendFeedbackParams = { - name: feedback.name, - email: feedback.email, - message: feedback.comments, - associatedEventId: feedback.event_id, - }; - captureFeedback(feedbackParams); -} - /** * Creates a new scope with and executes the given operation within. * The scope is automatically removed once the operation diff --git a/packages/core/src/js/tools/collectModules.ts b/packages/core/src/js/tools/collectModules.ts index ed386b2f50..15cca70dfb 100755 --- a/packages/core/src/js/tools/collectModules.ts +++ b/packages/core/src/js/tools/collectModules.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { argv } from 'process'; - import ModulesCollector from './ModulesCollector'; const sourceMapPath: string | undefined = argv[2]; diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 79c9fd43b0..c2e41dd579 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -3,16 +3,17 @@ import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro'; import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver'; import * as process from 'process'; import { env } from 'process'; - import { enableLogger } from './enableLogger'; +import { withSentryMiddleware } from './metroMiddleware'; import { setSentryBabelTransformerOptions, setSentryDefaultBabelTransformerPathEnv, } from './sentryBabelTransformerUtils'; -import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } from './sentryMetroSerializer'; +import { createSentryMetroSerializer, unstableBeforeAssetSerializationDebugIdPlugin } from './sentryMetroSerializer'; +import { unstableReleaseConstantsPlugin } from './sentryReleaseInjector'; import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; + export * from './sentryMetroSerializer'; -import { withSentryMiddleware } from './metroMiddleware'; enableLogger(); @@ -44,6 +45,13 @@ export interface SentryExpoConfigOptions { * Pass a custom `getDefaultConfig` function to override the default Expo configuration getter. */ getDefaultConfig?: typeof getSentryExpoConfig; + + /** + * For Expo Web, inject `release` and `version` options from `app.json`, the Expo Application Config. + * + * @default true + */ + injectReleaseForWeb?: boolean; } /** @@ -93,7 +101,8 @@ export function getSentryExpoConfig( ...options, unstable_beforeAssetSerializationPlugins: [ ...(options.unstable_beforeAssetSerializationPlugins || []), - unstable_beforeAssetSerializationPlugin, + ...(options.injectReleaseForWeb ?? true ? [unstableReleaseConstantsPlugin(projectRoot)] : []), + unstableBeforeAssetSerializationDebugIdPlugin, ], }); @@ -140,7 +149,7 @@ export function withSentryBabelTransformer( config: MetroConfig, annotateReactComponents: true | { ignoredComponents?: string[] }, ): MetroConfig { - const defaultBabelTransformerPath = config.transformer && config.transformer.babelTransformerPath; + const defaultBabelTransformerPath = config.transformer?.babelTransformerPath; logger.debug('Default Babel transformer path from `config.transformer`:', defaultBabelTransformerPath); if (!defaultBabelTransformerPath) { @@ -270,10 +279,10 @@ export function withSentryFramesCollapsed(config: MetroConfig): MetroConfig { originalCustomization: MetroCustomizeFrame | undefined, ): MetroCustomizeFrame => ({ ...originalCustomization, - collapse: (originalCustomization && originalCustomization.collapse) || collapseSentryInternalFrames(frame), + collapse: originalCustomization?.collapse || collapseSentryInternalFrames(frame), }); - const maybePromiseCustomization = (originalCustomizeFrame && originalCustomizeFrame(frame)) || undefined; + const maybePromiseCustomization = originalCustomizeFrame?.(frame) || undefined; if (maybePromiseCustomization !== undefined && 'then' in maybePromiseCustomization) { return maybePromiseCustomization.then(originalCustomization => diff --git a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts index 6484291cb9..892e907291 100644 --- a/packages/core/src/js/tools/sentryBabelTransformerUtils.ts +++ b/packages/core/src/js/tools/sentryBabelTransformerUtils.ts @@ -1,7 +1,6 @@ import componentAnnotatePlugin from '@sentry/babel-plugin-component-annotate'; import { logger } from '@sentry/core'; import * as process from 'process'; - import type { BabelTransformer, BabelTransformerArgs } from './vendor/metro/metroBabelTransformer'; export type SentryBabelTransformerOptions = { annotateReactComponents?: { ignoredComponents?: string[] } }; @@ -46,7 +45,7 @@ export function loadDefaultBabelTransformer(): BabelTransformer { export function setSentryBabelTransformerOptions(options: SentryBabelTransformerOptions): void { let optionsString: string | null = null; try { - logger.debug(`Stringifying Sentry Babel transformer options`, options); + logger.debug('Stringifying Sentry Babel transformer options', options); optionsString = JSON.stringify(options); } catch (e) { // eslint-disable-next-line no-console diff --git a/packages/core/src/js/tools/sentryMetroSerializer.ts b/packages/core/src/js/tools/sentryMetroSerializer.ts index fca0979440..8e31f512b9 100644 --- a/packages/core/src/js/tools/sentryMetroSerializer.ts +++ b/packages/core/src/js/tools/sentryMetroSerializer.ts @@ -1,25 +1,28 @@ import * as crypto from 'crypto'; // eslint-disable-next-line import/no-extraneous-dependencies import type { MixedOutput, Module, ReadOnlyGraph } from 'metro'; -// eslint-disable-next-line import/no-extraneous-dependencies -import * as countLines from 'metro/src/lib/countLines'; - import type { Bundle, MetroSerializer, MetroSerializerOutput, SerializedBundle, VirtualJSOutput } from './utils'; -import { createDebugIdSnippet, createSet, determineDebugIdFromBundleSource, stringToUUID } from './utils'; +import { + createDebugIdSnippet, + createVirtualJSModule, + determineDebugIdFromBundleSource, + prependModule, + stringToUUID, +} from './utils'; import { createDefaultMetroSerializer } from './vendor/metro/utils'; type SourceMap = Record; const DEBUG_ID_PLACE_HOLDER = '__debug_id_place_holder__'; const DEBUG_ID_MODULE_PATH = '__debugid__'; -const PRELUDE_MODULE_PATH = '__prelude__'; + const SOURCE_MAP_COMMENT = '//# sourceMappingURL='; const DEBUG_ID_COMMENT = '//# debugId='; /** * Adds Sentry Debug ID polyfill module to the bundle. */ -export function unstable_beforeAssetSerializationPlugin({ +export function unstableBeforeAssetSerializationDebugIdPlugin({ premodules, debugId, }: { @@ -39,7 +42,7 @@ export function unstable_beforeAssetSerializationPlugin({ } const debugIdModule = createDebugIdModule(debugId); - return [...addDebugIdModule(premodules, debugIdModule)]; + return prependModule(premodules, debugIdModule); } /** @@ -64,7 +67,7 @@ export const createSentryMetroSerializer = (customSerializer?: MetroSerializer): const debugIdModule = createDebugIdModule(DEBUG_ID_PLACE_HOLDER); options.sentryBundleCallback = createSentryBundleCallback(debugIdModule); - const modifiedPreModules = addDebugIdModule(preModules, debugIdModule); + const modifiedPreModules = prependModule(preModules, debugIdModule); // Run wrapped serializer const serializerResult = serializer(entryPoint, modifiedPreModules, graph, options); @@ -121,25 +124,6 @@ function createSentryBundleCallback(debugIdModule: Module & { s }; } -function addDebugIdModule( - preModules: readonly Module[], - debugIdModule: Module, -): readonly Module[] { - const modifiedPreModules = [...preModules]; - if ( - modifiedPreModules.length > 0 && - modifiedPreModules[0] !== undefined && - modifiedPreModules[0].path === PRELUDE_MODULE_PATH - ) { - // prelude module must be first as it measures the bundle startup time - modifiedPreModules.unshift(preModules[0] as Module); - modifiedPreModules[1] = debugIdModule; - } else { - modifiedPreModules.unshift(debugIdModule); - } - return modifiedPreModules; -} - async function extractSerializerResult(serializerResult: MetroSerializerOutput): Promise { if (typeof serializerResult === 'string') { return { code: serializerResult, map: '{}' }; @@ -158,27 +142,7 @@ async function extractSerializerResult(serializerResult: MetroSerializerOutput): } function createDebugIdModule(debugId: string): Module & { setSource: (code: string) => void } { - let debugIdCode = createDebugIdSnippet(debugId); - - return { - setSource: (code: string) => { - debugIdCode = code; - }, - dependencies: new Map(), - getSource: () => Buffer.from(debugIdCode), - inverseDependencies: createSet(), - path: DEBUG_ID_MODULE_PATH, - output: [ - { - type: 'js/script/virtual', - data: { - code: debugIdCode, - lineCount: countLines(debugIdCode), - map: [], - }, - }, - ], - }; + return createVirtualJSModule(DEBUG_ID_MODULE_PATH, createDebugIdSnippet(debugId)); } function calculateDebugId(bundle: Bundle): string { diff --git a/packages/core/src/js/tools/sentryReleaseInjector.ts b/packages/core/src/js/tools/sentryReleaseInjector.ts new file mode 100644 index 0000000000..527ecf4e60 --- /dev/null +++ b/packages/core/src/js/tools/sentryReleaseInjector.ts @@ -0,0 +1,46 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import type { VirtualJSOutput } from './utils'; +import { createVirtualJSModule, getExpoConfig, prependModule } from './utils'; + +const RELEASE_CONSTANTS_MODULE_PATH = '__sentryReleaseConstants__'; + +/** + * Adds Sentry Release constants to the bundle. + */ +export const unstableReleaseConstantsPlugin = + (projectRoot: string) => + ({ graph, premodules }: { graph: ReadOnlyGraph; premodules: Module[]; debugId?: string }): Module[] => { + const notWeb = graph.transformOptions.platform !== 'web'; + if (notWeb) { + return premodules; + } + + const { name, version } = getExpoConfig(projectRoot); + + if (!name || !version) { + return premodules; + } + + return prependModule( + premodules, + createSentryReleaseModule({ + name, + version, + }), + ); + }; + +function createSentryReleaseModule({ + name, + version, +}: { + name: string; + version: string; +}): Module & { setSource: (code: string) => void } { + return createVirtualJSModule(RELEASE_CONSTANTS_MODULE_PATH, createReleaseConstantsSnippet({ name, version })); +} + +function createReleaseConstantsSnippet({ name, version }: { name: string; version: string }): string { + return `var SENTRY_RELEASE;SENTRY_RELEASE={name: "${name}", version: "${version}"};`; +} diff --git a/packages/core/src/js/tools/utils.ts b/packages/core/src/js/tools/utils.ts index 769dc9abd4..7a7aef2881 100644 --- a/packages/core/src/js/tools/utils.ts +++ b/packages/core/src/js/tools/utils.ts @@ -1,8 +1,10 @@ import * as crypto from 'crypto'; // eslint-disable-next-line import/no-extraneous-dependencies -import type { Module, ReadOnlyGraph, SerializerOptions } from 'metro'; +import type { MixedOutput, Module, ReadOnlyGraph, SerializerOptions } from 'metro'; // eslint-disable-next-line import/no-extraneous-dependencies import type CountingSet from 'metro/src/lib/CountingSet'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as countLines from 'metro/src/lib/countLines'; // Variant of MixedOutput // https://github.com/facebook/metro/blob/9b85f83c9cc837d8cd897aa7723be7da5b296067/packages/metro/src/DeltaBundler/types.flow.js#L21 @@ -98,3 +100,83 @@ function resolveSetCreator(): () => CountingSet { } export const createSet = resolveSetCreator(); + +const PRELUDE_MODULE_PATH = '__prelude__'; + +/** + * Prepends the module after default required prelude modules. + */ +export function prependModule( + modules: readonly Module[], + module: Module, +): Module[] { + const modifiedPreModules = [...modules]; + if ( + modifiedPreModules.length > 0 && + modifiedPreModules[0] !== undefined && + modifiedPreModules[0].path === PRELUDE_MODULE_PATH + ) { + // prelude module must be first as it measures the bundle startup time + modifiedPreModules.unshift(modules[0] as Module); + modifiedPreModules[1] = module; + } else { + modifiedPreModules.unshift(module); + } + return modifiedPreModules; +} + +/** + * Creates a virtual JS module with the given path and code. + */ +export function createVirtualJSModule( + modulePath: string, + moduleCode: string, +): Module & { setSource: (code: string) => void } { + let sourceCode = moduleCode; + + return { + setSource: (code: string) => { + sourceCode = code; + }, + dependencies: new Map(), + getSource: () => Buffer.from(sourceCode), + inverseDependencies: createSet(), + path: modulePath, + output: [ + { + type: 'js/script/virtual', + data: { + code: sourceCode, + lineCount: countLines(sourceCode), + map: [], + }, + }, + ], + }; +} + +/** + * Tries to load Expo config using `@expo/config` package. + */ +export function getExpoConfig(projectRoot: string): Partial<{ + name: string; + version: string; +}> { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies + const expoConfig = require('@expo/config') as { + getConfig?: (projectRoot: string) => { exp: Record }; + }; + if (expoConfig.getConfig) { + const { exp } = expoConfig.getConfig(projectRoot); + return { + name: typeof exp.name === 'string' && exp.name ? exp.name : undefined, + version: typeof exp.version === 'string' && exp.version ? exp.version : undefined, + }; + } + } catch { + // @expo/config not available, do nothing + } + + return {}; +} diff --git a/packages/core/src/js/tools/vendor/metro/utils.ts b/packages/core/src/js/tools/vendor/metro/utils.ts index 4ce9866b17..bcdf75ab1e 100644 --- a/packages/core/src/js/tools/vendor/metro/utils.ts +++ b/packages/core/src/js/tools/vendor/metro/utils.ts @@ -32,7 +32,6 @@ import * as baseJSBundle from 'metro/src/DeltaBundler/Serializers/baseJSBundle'; import * as sourceMapString from 'metro/src/DeltaBundler/Serializers/sourceMapString'; // eslint-disable-next-line import/no-extraneous-dependencies import * as bundleToString from 'metro/src/lib/bundleToString'; - import type { MetroSerializer } from '../../utils'; type NewSourceMapStringExport = { diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 293391a834..a9fd41852f 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -3,7 +3,6 @@ import { addBreadcrumb, dropUndefinedKeys, getClient, logger, SEMANTIC_ATTRIBUTE import * as React from 'react'; import type { GestureResponderEvent } from 'react-native'; import { StyleSheet, View } from 'react-native'; - import { createIntegration } from './integrations/factory'; import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; @@ -121,8 +120,12 @@ class TouchEventBoundary extends React.Component { const level = 'info' as SeverityLevel; const root = touchPath[0]; - const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`; + if (!root) { + logger.warn('[TouchEvents] No root component found in touch path.'); + return; + } + const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`; const crumb = { category: this.props.breadcrumbCategory, data: { path: touchPath }, diff --git a/packages/core/src/js/tracing/gesturetracing.ts b/packages/core/src/js/tracing/gesturetracing.ts index 4dd012fe7b..8cf0a329f8 100644 --- a/packages/core/src/js/tracing/gesturetracing.ts +++ b/packages/core/src/js/tracing/gesturetracing.ts @@ -1,6 +1,5 @@ import type { Breadcrumb } from '@sentry/core'; import { addBreadcrumb, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; - import { startUserInteractionSpan } from './integrations/userInteraction'; import { UI_ACTION } from './ops'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index b5576d8b2f..5af3d5a8a5 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -10,7 +10,6 @@ import { startInactiveSpan, timestampInSeconds, } from '@sentry/core'; - import { getAppRegistryIntegration } from '../../integrations/appRegistry'; import { APP_START_COLD as APP_START_COLD_MEASUREMENT, @@ -255,6 +254,13 @@ export const appStartIntegration = ({ }; async function captureStandaloneAppStart(): Promise { + if (!_client) { + // If client is not set, SDK was not initialized, logger is thus disabled + // eslint-disable-next-line no-console + console.warn('[AppStart] Could not capture App Start, missing client, call `Sentry.init` first.'); + return; + } + if (!standalone) { logger.debug( '[AppStart] App start tracking is enabled. App start will be added to the first transaction as a child span.', @@ -319,7 +325,7 @@ export const appStartIntegration = ({ return; } - if (!event.contexts || !event.contexts.trace) { + if (!event.contexts?.trace) { logger.warn('[AppStart] Transaction event is missing trace context. Can not attach app start.'); return; } @@ -420,7 +426,7 @@ export const appStartIntegration = ({ const op = appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP; const appStartSpanJSON: SpanJSON = createSpanJSON({ op, - description: appStart.type === 'cold' ? 'Cold App Start' : 'Warm App Start', + description: appStart.type === 'cold' ? 'Cold Start' : 'Warm Start', start_timestamp: appStartTimestampSeconds, timestamp: appStartEndTimestampSeconds, trace_id: event.contexts.trace.trace_id, @@ -451,7 +457,7 @@ export const appStartIntegration = ({ event.measurements = event.measurements || {}; event.measurements[measurementKey] = measurementValue; logger.debug( - `[AppStart] Added app start measurement to transaction event.`, + '[AppStart] Added app start measurement to transaction event.', JSON.stringify(measurementValue, undefined, 2), ); } diff --git a/packages/core/src/js/tracing/integrations/nativeFrames.ts b/packages/core/src/js/tracing/integrations/nativeFrames.ts index ceec914b88..07093e4ba2 100644 --- a/packages/core/src/js/tracing/integrations/nativeFrames.ts +++ b/packages/core/src/js/tracing/integrations/nativeFrames.ts @@ -1,6 +1,5 @@ import type { Client, Event, Integration, Measurements, MeasurementUnit, Span } from '@sentry/core'; import { logger, timestampInSeconds } from '@sentry/core'; - import type { NativeFramesResponse } from '../../NativeRNSentry'; import { AsyncExpiringMap } from '../../utils/AsyncExpiringMap'; import { isRootSpan } from '../../utils/span'; @@ -60,7 +59,7 @@ export const createNativeFramesIntegrations = (enable: boolean | undefined): Int export const nativeFramesIntegration = (): Integration => { /** The native frames at the finish time of the most recent span. */ let _lastChildSpanEndFrames: NativeFramesResponseWithTimestamp | null = null; - const _spanToNativeFramesAtStartMap: AsyncExpiringMap = new AsyncExpiringMap({ + const _spanToNativeFramesAtStartMap: AsyncExpiringMap = new AsyncExpiringMap({ ttl: START_FRAMES_TIMEOUT_MS, }); const _spanToNativeFramesAtEndMap: AsyncExpiringMap = diff --git a/packages/core/src/js/tracing/integrations/stalltracking.ts b/packages/core/src/js/tracing/integrations/stalltracking.ts index e2b05c8d9d..f0d6c96eaf 100644 --- a/packages/core/src/js/tracing/integrations/stalltracking.ts +++ b/packages/core/src/js/tracing/integrations/stalltracking.ts @@ -3,7 +3,6 @@ import type { Client, Integration, Measurements, MeasurementUnit, Span } from '@ import { getRootSpan, logger, spanToJSON, timestampInSeconds } from '@sentry/core'; import type { AppStateStatus } from 'react-native'; import { AppState } from 'react-native'; - import { STALL_COUNT, STALL_LONGEST_TIME, STALL_TOTAL_TIME } from '../../measurements'; import { isRootSpan } from '../../utils/span'; import { getLatestChildSpanEndTimestamp, isNearToNow, setSpanMeasurement } from '../utils'; diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index 52cd915634..d1349708ec 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -1,6 +1,5 @@ import type { Event, Integration, SpanJSON } from '@sentry/core'; import { logger } from '@sentry/core'; - import { NATIVE } from '../../wrapper'; import { UI_LOAD_FULL_DISPLAY, UI_LOAD_INITIAL_DISPLAY } from '../ops'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin'; @@ -30,7 +29,7 @@ export const timeToDisplayIntegration = (): Integration => { return event; } - const rootSpanId = event.contexts.trace.span_id; + const rootSpanId = event.contexts?.trace?.span_id; if (!rootSpanId) { logger.warn(`[${INTEGRATION_NAME}] No root span id found in transaction.`); return event; @@ -54,17 +53,19 @@ export const timeToDisplayIntegration = (): Integration => { }); const ttfdSpan = await addTimeToFullDisplay({ event, rootSpanId, transactionStartTimestampSeconds, ttidSpan }); - if (ttidSpan && ttidSpan.start_timestamp && ttidSpan.timestamp) { + if (ttidSpan?.start_timestamp && ttidSpan?.timestamp) { event.measurements['time_to_initial_display'] = { value: (ttidSpan.timestamp - ttidSpan.start_timestamp) * 1000, unit: 'millisecond', }; } - if (ttfdSpan && ttfdSpan.start_timestamp && ttfdSpan.timestamp) { + if (ttfdSpan?.start_timestamp && ttfdSpan?.timestamp) { const durationMs = (ttfdSpan.timestamp - ttfdSpan.start_timestamp) * 1000; if (isDeadlineExceeded(durationMs)) { - event.measurements['time_to_full_display'] = event.measurements['time_to_initial_display']; + if (event.measurements['time_to_initial_display']) { + event.measurements['time_to_full_display'] = event.measurements['time_to_initial_display']; + } } else { event.measurements['time_to_full_display'] = { value: durationMs, @@ -100,6 +101,8 @@ async function addTimeToInitialDisplay({ }): Promise { const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); + event.spans = event.spans || []; + let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); if (ttidSpan && (ttidSpan.status === undefined || ttidSpan.status === 'ok') && !ttidEndTimestampSeconds) { @@ -117,7 +120,7 @@ async function addTimeToInitialDisplay({ }); } - if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { + if (ttidSpan?.status && ttidSpan.status !== 'ok') { ttidSpan.status = 'ok'; ttidSpan.timestamp = ttidEndTimestampSeconds; logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); @@ -204,17 +207,19 @@ async function addTimeToFullDisplay({ return undefined; } + event.spans = event.spans || []; + let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; - const ttfdIsBeforeTtid = ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; - if (ttfdIsBeforeTtid) { + const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; + if (ttfdIsBeforeTtid && ttidSpan.timestamp) { ttfdAdjustedEndTimestampSeconds = ttidSpan.timestamp; } const durationMs = (ttfdAdjustedEndTimestampSeconds - transactionStartTimestampSeconds) * 1000; - if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { + if (ttfdSpan?.status && ttfdSpan.status !== 'ok') { ttfdSpan.status = 'ok'; ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); diff --git a/packages/core/src/js/tracing/integrations/userInteraction.ts b/packages/core/src/js/tracing/integrations/userInteraction.ts index 378181016c..a42f71d62e 100644 --- a/packages/core/src/js/tracing/integrations/userInteraction.ts +++ b/packages/core/src/js/tracing/integrations/userInteraction.ts @@ -7,7 +7,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; - import type { ReactNativeClientOptions } from '../../options'; import { onlySampleIfChildSpans } from '../onSpanEndUtils'; import { SPAN_ORIGIN_MANUAL_INTERACTION } from '../origin'; diff --git a/packages/core/src/js/tracing/onSpanEndUtils.ts b/packages/core/src/js/tracing/onSpanEndUtils.ts index 40365b33fd..d63913e69f 100644 --- a/packages/core/src/js/tracing/onSpanEndUtils.ts +++ b/packages/core/src/js/tracing/onSpanEndUtils.ts @@ -2,7 +2,6 @@ import type { Client, Span } from '@sentry/core'; import { getSpanDescendants, logger, SPAN_STATUS_ERROR, spanToJSON } from '@sentry/core'; import type { AppStateStatus } from 'react-native'; import { AppState } from 'react-native'; - import { isRootSpan, isSentrySpan } from '../utils/span'; /** @@ -44,7 +43,7 @@ export const adjustTransactionDuration = (client: Client, span: Span, maxDuratio }); }; -export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span): void => { +export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span | undefined): void => { if (!client) { logger.warn('Could not hook on spanEnd event because client is not defined.'); return; @@ -129,7 +128,7 @@ export const cancelInBackground = (client: Client, span: Span): void => { client.on('spanEnd', (endedSpan: Span) => { if (endedSpan === span) { logger.debug(`Removing AppState listener for ${spanToJSON(span).op} transaction.`); - subscription && subscription.remove && subscription.remove(); + subscription?.remove?.(); } }); }; diff --git a/packages/core/src/js/tracing/reactnativenavigation.ts b/packages/core/src/js/tracing/reactnativenavigation.ts index 0b0f696c68..33a3275fb0 100644 --- a/packages/core/src/js/tracing/reactnativenavigation.ts +++ b/packages/core/src/js/tracing/reactnativenavigation.ts @@ -7,7 +7,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, } from '@sentry/core'; - import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; @@ -129,7 +128,7 @@ export const reactNativeNavigationIntegration = ({ } latestNavigationSpan = startGenericIdleNavigationSpan( - tracing && tracing.options.beforeStartSpan + tracing?.options.beforeStartSpan ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) : getDefaultIdleNavigationSpanOptions(), idleSpanOptions, diff --git a/packages/core/src/js/tracing/reactnativeprofiler.tsx b/packages/core/src/js/tracing/reactnativeprofiler.tsx index ed5a9158e8..0798c92c94 100644 --- a/packages/core/src/js/tracing/reactnativeprofiler.tsx +++ b/packages/core/src/js/tracing/reactnativeprofiler.tsx @@ -1,6 +1,5 @@ import { logger, timestampInSeconds } from '@sentry/core'; import { getClient, Profiler } from '@sentry/react'; - import { getAppRegistryIntegration } from '../integrations/appRegistry'; import { createIntegration } from '../integrations/factory'; import { _captureAppStart, _setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart'; @@ -12,13 +11,15 @@ const ReactNativeProfilerGlobalState = { }, }; +type ProfilerConstructorProps = ConstructorParameters[0]; + /** * Custom profiler for the React Native app root. */ export class ReactNativeProfiler extends Profiler { public readonly name: string = 'ReactNativeProfiler'; - public constructor(props: ConstructorParameters[0]) { + public constructor(props: ProfilerConstructorProps) { _setRootComponentCreationTimestampMs(timestampInSeconds() * 1000); super(props); } @@ -47,7 +48,7 @@ export class ReactNativeProfiler extends Profiler { return; } - client.addIntegration && client.addIntegration(createIntegration(this.name)); + client.addIntegration?.(createIntegration(this.name)); const appRegistryIntegration = getAppRegistryIntegration(client); if (appRegistryIntegration && typeof appRegistryIntegration.onRunApplication === 'function') { diff --git a/packages/core/src/js/tracing/reactnativetracing.ts b/packages/core/src/js/tracing/reactnativetracing.ts index 8874f6769f..74e56c51b2 100644 --- a/packages/core/src/js/tracing/reactnativetracing.ts +++ b/packages/core/src/js/tracing/reactnativetracing.ts @@ -2,7 +2,6 @@ import { instrumentOutgoingRequests } from '@sentry/browser'; import type { Client, Event, Integration, StartSpanOptions } from '@sentry/core'; import { getClient } from '@sentry/core'; - import { isWeb } from '../utils/environment'; import { getDevServer } from './../integrations/debugsymbolicatorutils'; import { addDefaultOpForSpanFrom, addThreadInfoToSpan, defaultIdleOptions } from './span'; diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 58afcf1f4a..360a5ae807 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -12,7 +12,6 @@ import { startInactiveSpan, timestampInSeconds, } from '@sentry/core'; - import { getAppRegistryIntegration } from '../integrations/appRegistry'; import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; @@ -30,6 +29,7 @@ import { startIdleNavigationSpan as startGenericIdleNavigationSpan, } from './span'; import { addTimeToInitialDisplayFallback } from './timeToDisplayFallback'; + export const INTEGRATION_NAME = 'ReactNavigation'; const NAVIGATION_HISTORY_MAX_SIZE = 200; @@ -220,6 +220,7 @@ export const reactNavigationIntegration = ({ const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined; if ( useDispatchedActionData && + navigationActionType && [ // Process common actions 'PRELOAD', @@ -241,7 +242,7 @@ export const reactNavigationIntegration = ({ } latestNavigationSpan = startGenericIdleNavigationSpan( - tracing && tracing.options.beforeStartSpan + tracing?.options.beforeStartSpan ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) : getDefaultIdleNavigationSpanOptions(), idleSpanOptions, @@ -252,12 +253,12 @@ export const reactNavigationIntegration = ({ ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); } - if (enableTimeToInitialDisplay) { - NATIVE.setActiveSpanId(latestNavigationSpan?.spanContext().spanId); + if (enableTimeToInitialDisplay && latestNavigationSpan) { + NATIVE.setActiveSpanId(latestNavigationSpan.spanContext().spanId); navigationProcessingSpan = startInactiveSpan({ op: 'navigation.processing', name: 'Navigation dispatch to navigation cancelled or screen mounted', - startTime: latestNavigationSpan && spanToJSON(latestNavigationSpan).start_timestamp, + startTime: spanToJSON(latestNavigationSpan).start_timestamp, }); navigationProcessingSpan.setAttribute( SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts index ecb50cda76..7db6fb2496 100644 --- a/packages/core/src/js/tracing/span.ts +++ b/packages/core/src/js/tracing/span.ts @@ -1,6 +1,6 @@ import type { Client, Scope, Span, SpanJSON, StartSpanOptions } from '@sentry/core'; import { - generatePropagationContext, + generateTraceId, getActiveSpan, getClient, getCurrentScope, @@ -13,7 +13,6 @@ import { startIdleSpan as coreStartIdleSpan, } from '@sentry/core'; import { AppState } from 'react-native'; - import { isRootSpan } from '../utils/span'; import { adjustTransactionDuration, cancelInBackground } from './onSpanEndUtils'; import { @@ -54,7 +53,7 @@ export const startIdleNavigationSpan = ( ): Span | undefined => { const client = getClient(); if (!client) { - logger.warn(`[startIdleNavigationSpan] Can't create route change span, missing client.`); + logger.warn("[startIdleNavigationSpan] Can't create route change span, missing client."); return undefined; } @@ -101,7 +100,7 @@ export const startIdleSpan = ( ): Span => { const client = getClient(); if (!client) { - logger.warn(`[startIdleSpan] Can't create idle span, missing client.`); + logger.warn("[startIdleSpan] Can't create idle span, missing client."); return new SentryNonRecordingSpan(); } @@ -111,7 +110,7 @@ export const startIdleSpan = ( return new SentryNonRecordingSpan(); } - getCurrentScope().setPropagationContext(generatePropagationContext()); + getCurrentScope().setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); const span = coreStartIdleSpan(startSpanOption, { finalTimeout, idleTimeout }); cancelInBackground(client, span); @@ -134,7 +133,7 @@ export function getDefaultIdleNavigationSpanOptions(): StartSpanOptions { * Checks if the span is a Sentry User Interaction span. */ export function isSentryInteractionSpan(span: Span): boolean { - return [SPAN_ORIGIN_AUTO_INTERACTION, SPAN_ORIGIN_MANUAL_INTERACTION].includes(spanToJSON(span).origin); + return [SPAN_ORIGIN_AUTO_INTERACTION, SPAN_ORIGIN_MANUAL_INTERACTION].includes(spanToJSON(span).origin || ''); } export const SCOPE_SPAN_FIELD = '_sentrySpan'; diff --git a/packages/core/src/js/tracing/timeToDisplayFallback.ts b/packages/core/src/js/tracing/timeToDisplayFallback.ts index cd71b21df0..e854d43477 100644 --- a/packages/core/src/js/tracing/timeToDisplayFallback.ts +++ b/packages/core/src/js/tracing/timeToDisplayFallback.ts @@ -13,6 +13,6 @@ export const addTimeToInitialDisplayFallback = ( spanIdToTimeToInitialDisplayFallback.set(spanId, timestampSeconds); }; -export const getTimeToInitialDisplayFallback = async (spanId: string): Promise => { +export const getTimeToInitialDisplayFallback = async (spanId: string): Promise => { return spanIdToTimeToInitialDisplayFallback.get(spanId); }; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index f33216b875..3ffa223c46 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -2,7 +2,6 @@ import type { Span,StartSpanOptions } from '@sentry/core'; import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core'; import * as React from 'react'; import { useState } from 'react'; - import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; @@ -99,13 +98,13 @@ export function startTimeToInitialDisplaySpan( ): Span | undefined { const activeSpan = getActiveSpan(); if (!activeSpan) { - logger.warn(`[TimeToDisplay] No active span found to attach ui.load.initial_display to.`); + logger.warn('[TimeToDisplay] No active span found to attach ui.load.initial_display to.'); return undefined; } const existingSpan = getSpanDescendants(activeSpan).find((span) => spanToJSON(span).op === 'ui.load.initial_display'); if (existingSpan) { - logger.debug(`[TimeToDisplay] Found existing ui.load.initial_display span.`); + logger.debug('[TimeToDisplay] Found existing ui.load.initial_display span.'); return existingSpan } @@ -148,7 +147,7 @@ export function startTimeToFullDisplaySpan( ): Span | undefined { const activeSpan = getActiveSpan(); if (!activeSpan) { - logger.warn(`[TimeToDisplay] No active span found to attach ui.load.full_display to.`); + logger.warn('[TimeToDisplay] No active span found to attach ui.load.full_display to.'); return undefined; } @@ -156,13 +155,13 @@ export function startTimeToFullDisplaySpan( const initialDisplaySpan = descendantSpans.find((span) => spanToJSON(span).op === 'ui.load.initial_display'); if (!initialDisplaySpan) { - logger.warn(`[TimeToDisplay] No initial display span found to attach ui.load.full_display to.`); + logger.warn('[TimeToDisplay] No initial display span found to attach ui.load.full_display to.'); return undefined; } const existingSpan = descendantSpans.find((span) => spanToJSON(span).op === 'ui.load.full_display'); if (existingSpan) { - logger.debug(`[TimeToDisplay] Found existing ui.load.full_display span.`); + logger.debug('[TimeToDisplay] Found existing ui.load.full_display span.'); return existingSpan; } @@ -183,7 +182,7 @@ export function startTimeToFullDisplaySpan( fullDisplaySpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }); fullDisplaySpan.end(spanToJSON(initialDisplaySpan).timestamp); setSpanDurationAsMeasurement('time_to_full_display', fullDisplaySpan); - logger.warn(`[TimeToDisplay] Full display span deadline_exceeded.`); + logger.warn('[TimeToDisplay] Full display span deadline_exceeded.'); }, options.timeoutMs); fill(fullDisplaySpan, 'end', (originalEnd: Span['end']) => (endTimestamp?: Parameters[0]) => { @@ -216,17 +215,17 @@ export function updateInitialDisplaySpan( span?: Span; } = {}): void { if (!span) { - logger.warn(`[TimeToDisplay] No span found or created, possibly performance is disabled.`); + logger.warn('[TimeToDisplay] No span found or created, possibly performance is disabled.'); return; } if (!activeSpan) { - logger.warn(`[TimeToDisplay] No active span found to attach ui.load.initial_display to.`); + logger.warn('[TimeToDisplay] No active span found to attach ui.load.initial_display to.'); return; } if (spanToJSON(span).parent_span_id !== spanToJSON(activeSpan).span_id) { - logger.warn(`[TimeToDisplay] Initial display span is not a child of current active span.`); + logger.warn('[TimeToDisplay] Initial display span is not a child of current active span.'); return; } @@ -251,7 +250,7 @@ export function updateInitialDisplaySpan( function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void { const activeSpan = getActiveSpan(); if (!activeSpan) { - logger.warn(`[TimeToDisplay] No active span found to update ui.load.full_display in.`); + logger.warn('[TimeToDisplay] No active span found to update ui.load.full_display in.'); return; } @@ -268,7 +267,7 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl isAutoInstrumented: true, }); if (!span) { - logger.warn(`[TimeToDisplay] No TimeToFullDisplay span found or created, possibly performance is disabled.`); + logger.warn('[TimeToDisplay] No TimeToFullDisplay span found or created, possibly performance is disabled.'); return; } @@ -279,7 +278,7 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl } if (initialDisplayEndTimestamp > frameTimestampSeconds) { - logger.warn(`[TimeToDisplay] Using initial display end. Full display end frame timestamp is before initial display end.`); + logger.warn('[TimeToDisplay] Using initial display end. Full display end frame timestamp is before initial display end.'); span.end(initialDisplayEndTimestamp); } else { span.end(frameTimestampSeconds); @@ -339,6 +338,6 @@ function createTimeToDisplay({ return ; }; - TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`; + TimeToDisplayWrapper.displayName = 'TimeToDisplayWrapper'; return TimeToDisplayWrapper; } diff --git a/packages/core/src/js/tracing/timetodisplaynative.tsx b/packages/core/src/js/tracing/timetodisplaynative.tsx index d549fe8b87..217a922db8 100644 --- a/packages/core/src/js/tracing/timetodisplaynative.tsx +++ b/packages/core/src/js/tracing/timetodisplaynative.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import type { HostComponent } from 'react-native'; import { UIManager, View } from 'react-native'; - import { isExpoGo } from '../utils/environment'; import { ReactNativeLibraries } from '../utils/rnlibraries'; import type { RNSentryOnDrawReporterProps } from './timetodisplaynative.types'; diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index 27b49306ac..18dc9d37f4 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -12,7 +12,6 @@ import { timestampInSeconds, uuid4, } from '@sentry/core'; - import { RN_GLOBAL_OBJ } from '../utils/worldwide'; export const defaultTransactionSource: TransactionSource = 'component'; diff --git a/packages/core/src/js/transports/encodePolyfill.ts b/packages/core/src/js/transports/encodePolyfill.ts index 6e84209ed0..44aad44f6a 100644 --- a/packages/core/src/js/transports/encodePolyfill.ts +++ b/packages/core/src/js/transports/encodePolyfill.ts @@ -1,14 +1,30 @@ +import { getSentryCarrier } from '../utils/carrier'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { utf8ToBytes } from '../vendor'; export const useEncodePolyfill = (): void => { - if (!RN_GLOBAL_OBJ.__SENTRY__) { - (RN_GLOBAL_OBJ.__SENTRY__ as Partial<(typeof RN_GLOBAL_OBJ)['__SENTRY__']>) = {}; - } + const carrier = getSentryCarrier(); - RN_GLOBAL_OBJ.__SENTRY__.encodePolyfill = encodePolyfill; + if (RN_GLOBAL_OBJ.TextEncoder) { + // Hermes for RN 0.74 and later includes native TextEncoder + // https://github.com/facebook/hermes/commit/8fb0496d426a8e50d00385148d5fb498a6daa312 + carrier.encodePolyfill = globalEncodeFactory(RN_GLOBAL_OBJ.TextEncoder); + } else { + carrier.encodePolyfill = encodePolyfill; + } }; +/* + * The default encode polyfill is available in Hermes for RN 0.74 and later. + * https://github.com/facebook/hermes/commit/8fb0496d426a8e50d00385148d5fb498a6daa312 + */ +export const globalEncodeFactory = (Encoder: EncoderClass) => (text: string) => new Encoder().encode(text); + +type EncoderClass = Required['TextEncoder']; + +/* + * Encode polyfill runs in JS and might cause performance issues when processing large payloads. (~2+ MB) + */ export const encodePolyfill = (text: string): Uint8Array => { const bytes = new Uint8Array(utf8ToBytes(text)); return bytes; diff --git a/packages/core/src/js/transports/native.ts b/packages/core/src/js/transports/native.ts index ea0ae88129..c8037ea986 100644 --- a/packages/core/src/js/transports/native.ts +++ b/packages/core/src/js/transports/native.ts @@ -6,7 +6,6 @@ import type { TransportMakeRequestResponse, } from '@sentry/core'; import { makePromiseBuffer } from '@sentry/core'; - import { NATIVE } from '../wrapper'; export const DEFAULT_BUFFER_SIZE = 30; diff --git a/packages/core/src/js/utils/AsyncExpiringMap.ts b/packages/core/src/js/utils/AsyncExpiringMap.ts index 3f3906c9cd..bba14e01f1 100644 --- a/packages/core/src/js/utils/AsyncExpiringMap.ts +++ b/packages/core/src/js/utils/AsyncExpiringMap.ts @@ -7,7 +7,7 @@ export class AsyncExpiringMap { private _ttl: number; private _cleanupIntervalMs: number; private _map: Map | null }>; - private _cleanupInterval: ReturnType; + private _cleanupInterval: ReturnType | undefined; public constructor({ cleanupInterval = 5_000, @@ -30,7 +30,7 @@ export class AsyncExpiringMap { this.startCleanup(); } - if (typeof promise !== 'object' || !('then' in promise)) { + if (typeof promise !== 'object' || !promise || !('then' in promise)) { this._map.set(key, { value: promise, expiresAt: Date.now() + this._ttl, promise: null }); return; } @@ -116,7 +116,7 @@ export class AsyncExpiringMap { */ public ttl(key: K): number | undefined { const entry = this._map.get(key); - if (entry && entry.expiresAt) { + if (entry?.expiresAt) { const remainingTime = entry.expiresAt - Date.now(); return remainingTime > 0 ? remainingTime : 0; } @@ -143,7 +143,9 @@ export class AsyncExpiringMap { * Clear all entries. */ public clear(): void { - clearInterval(this._cleanupInterval); + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + } this._map.clear(); } @@ -151,7 +153,9 @@ export class AsyncExpiringMap { * Stop the cleanup interval. */ public stopCleanup(): void { - clearInterval(this._cleanupInterval); + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + } } /** diff --git a/packages/core/src/js/utils/carrier.ts b/packages/core/src/js/utils/carrier.ts new file mode 100644 index 0000000000..707e33ae78 --- /dev/null +++ b/packages/core/src/js/utils/carrier.ts @@ -0,0 +1,13 @@ +import { getMainCarrier, SDK_VERSION as CORE_SDK_VERSION } from '@sentry/core'; + +/* + * Will either get the existing sentry carrier, or create a new one. + * Based on https://github.com/getsentry/sentry-javascript/blob/f0fc41f6166857cd97a695f5cc9a18caf6a0bf43/packages/core/src/carrier.ts#L49 + */ +export const getSentryCarrier = (): SentryCarrier => { + const carrier = getMainCarrier(); + const __SENTRY__ = (carrier.__SENTRY__ = carrier.__SENTRY__ || {}); + return (__SENTRY__[CORE_SDK_VERSION] = __SENTRY__[CORE_SDK_VERSION] || {}); +}; + +type SentryCarrier = Required>['__SENTRY__'][string]; diff --git a/packages/core/src/js/utils/encode.ts b/packages/core/src/js/utils/encode.ts new file mode 100644 index 0000000000..b1d2f2e189 --- /dev/null +++ b/packages/core/src/js/utils/encode.ts @@ -0,0 +1,14 @@ +import { useEncodePolyfill } from '../transports/encodePolyfill'; +import { getSentryCarrier } from './carrier'; + +/** + * Encode a string to UTF8 array. + */ +export function encodeUTF8(input: string): Uint8Array { + const carrier = getSentryCarrier(); + if (!carrier.encodePolyfill) { + useEncodePolyfill(); + } + + return carrier.encodePolyfill!(input); +} diff --git a/packages/core/src/js/utils/envelope.ts b/packages/core/src/js/utils/envelope.ts index f115ca4bd2..ca6cb9a6d0 100644 --- a/packages/core/src/js/utils/envelope.ts +++ b/packages/core/src/js/utils/envelope.ts @@ -22,13 +22,12 @@ export function createUserFeedbackEnvelope( const headers: EventEnvelope[0] = { event_id: feedback.event_id, sent_at: new Date().toISOString(), - ...(metadata && - metadata.sdk && { - sdk: { - name: metadata.sdk.name, - version: metadata.sdk.version, - }, - }), + ...(metadata?.sdk && { + sdk: { + name: metadata.sdk.name, + version: metadata.sdk.version, + }, + }), ...(!!tunnel && !!dsn && { dsn: dsnToString(dsn) }), }; const item = createUserFeedbackEnvelopeItem(feedback); diff --git a/packages/core/src/js/utils/environment.ts b/packages/core/src/js/utils/environment.ts index 214532b031..af46053e70 100644 --- a/packages/core/src/js/utils/environment.ts +++ b/packages/core/src/js/utils/environment.ts @@ -1,7 +1,6 @@ import { Platform } from 'react-native'; - import { RN_GLOBAL_OBJ } from '../utils/worldwide'; -import { getExpoConstants } from './expomodules'; +import { getExpoConstants, getExpoGo } from './expomodules'; import { ReactNativeLibraries } from './rnlibraries'; /** Checks if the React Native Hermes engine is running */ @@ -35,8 +34,8 @@ export function isExpo(): boolean { /** Check if JS runs in Expo Go */ export function isExpoGo(): boolean { - const expoConstants = getExpoConstants(); - return (expoConstants && expoConstants.appOwnership) === 'expo'; + const expoGo = getExpoGo(); + return !!expoGo; } /** Check Expo Go version if available */ @@ -75,11 +74,7 @@ export function notMobileOs(): boolean { /** Returns Hermes Version if hermes is present in the runtime */ export function getHermesVersion(): string | undefined { - return ( - RN_GLOBAL_OBJ.HermesInternal && - RN_GLOBAL_OBJ.HermesInternal.getRuntimeProperties && - RN_GLOBAL_OBJ.HermesInternal.getRuntimeProperties()['OSS Release Version'] - ); + return RN_GLOBAL_OBJ.HermesInternal?.getRuntimeProperties?.()['OSS Release Version']; } /** Returns default environment based on __DEV__ */ @@ -91,8 +86,7 @@ export function getDefaultEnvironment(): 'development' | 'production' { export function isRunningInMetroDevServer(): boolean { if ( typeof RN_GLOBAL_OBJ.process !== 'undefined' && - RN_GLOBAL_OBJ.process.env && - RN_GLOBAL_OBJ.process.env.___SENTRY_METRO_DEV_SERVER___ === 'true' + RN_GLOBAL_OBJ.process.env?.___SENTRY_METRO_DEV_SERVER___ === 'true' ) { return true; } diff --git a/packages/core/src/js/utils/expoglobalobject.ts b/packages/core/src/js/utils/expoglobalobject.ts index c36afde03c..e9bef4b2da 100644 --- a/packages/core/src/js/utils/expoglobalobject.ts +++ b/packages/core/src/js/utils/expoglobalobject.ts @@ -7,7 +7,6 @@ * https://github.com/expo/expo/blob/b51b5139f2caa2a9495e4132437d7ca612276158/packages/expo-manifests/src/Manifests.ts */ export interface ExpoConstants { - appOwnership?: 'standalone' | 'expo' | 'guest'; /** * Deprecated. But until removed we can use it as user ID to match the native SDKs. */ @@ -65,10 +64,13 @@ export interface ExpoUpdates { createdAt?: Date | null; } +export type ExpoGo = unknown; + export interface ExpoGlobalObject { modules?: { ExponentConstants?: ExpoConstants; ExpoDevice?: ExpoDevice; ExpoUpdates?: ExpoUpdates; + ExpoGo?: ExpoGo; }; } diff --git a/packages/core/src/js/utils/expomodules.ts b/packages/core/src/js/utils/expomodules.ts index 9f606a4f76..d1a973dd5e 100644 --- a/packages/core/src/js/utils/expomodules.ts +++ b/packages/core/src/js/utils/expomodules.ts @@ -1,4 +1,4 @@ -import type { ExpoConstants, ExpoDevice, ExpoUpdates } from './expoglobalobject'; +import type { ExpoConstants, ExpoDevice, ExpoGo, ExpoUpdates } from './expoglobalobject'; import { RN_GLOBAL_OBJ } from './worldwide'; /** @@ -21,3 +21,10 @@ export function getExpoDevice(): ExpoDevice | undefined { export function getExpoUpdates(): ExpoUpdates | undefined { return RN_GLOBAL_OBJ.expo?.modules?.ExpoUpdates ?? undefined; } + +/** + * Returns the Expo Go module if present + */ +export function getExpoGo(): ExpoGo | undefined { + return RN_GLOBAL_OBJ.expo?.modules?.ExpoGo ?? undefined; +} diff --git a/packages/core/src/js/utils/primitiveConverter.ts b/packages/core/src/js/utils/primitiveConverter.ts new file mode 100644 index 0000000000..c57073c73f --- /dev/null +++ b/packages/core/src/js/utils/primitiveConverter.ts @@ -0,0 +1,26 @@ +import type { Primitive } from '@sentry/core'; + +/** + * Converts primitive to string. + */ +export function PrimitiveToString(primitive: Primitive): string | undefined { + if (primitive === null) { + return ''; + } + + switch (typeof primitive) { + case 'string': + return primitive; + case 'boolean': + return primitive == true ? 'True' : 'False'; + case 'number': + case 'bigint': + return `${primitive}`; + case 'undefined': + return undefined; + case 'symbol': + return primitive.toString(); + default: + return primitive as string; + } +} diff --git a/packages/core/src/js/utils/release.ts b/packages/core/src/js/utils/release.ts new file mode 100644 index 0000000000..c4bb985d3f --- /dev/null +++ b/packages/core/src/js/utils/release.ts @@ -0,0 +1,33 @@ +import { notWeb } from './environment'; +import { RN_GLOBAL_OBJ } from './worldwide'; + +/** + * + */ +export function createReleaseFromGlobalReleaseConstants(): string | undefined { + const globalRelease = RN_GLOBAL_OBJ.SENTRY_RELEASE; + if (!globalRelease) { + return undefined; + } + + const { name, version } = globalRelease; + if (!name || !version) { + return undefined; + } + + return `${name}@${version}`; +} + +/** + * + */ +export function getDefaultRelease(): string | undefined { + if (notWeb()) { + // Mobile platforms use native release from the Release integration. + return undefined; + } + + // Web platforms (Expo Web) use the global release constants. + // Release set in the options is need for Session and Replay integrations. + return createReleaseFromGlobalReleaseConstants(); +} diff --git a/packages/core/src/js/utils/rnlibraries.ts b/packages/core/src/js/utils/rnlibraries.ts index b05167c2ef..45e2d3f630 100644 --- a/packages/core/src/js/utils/rnlibraries.ts +++ b/packages/core/src/js/utils/rnlibraries.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { AppRegistry, Platform, TurboModuleRegistry } from 'react-native'; - import type * as ReactNative from '../vendor/react-native'; import type { ReactNativeLibrariesInterface } from './rnlibrariesinterface'; diff --git a/packages/core/src/js/utils/safe.ts b/packages/core/src/js/utils/safe.ts index 911c0d3de1..3508e31ebb 100644 --- a/packages/core/src/js/utils/safe.ts +++ b/packages/core/src/js/utils/safe.ts @@ -1,5 +1,4 @@ import { logger } from '@sentry/core'; - import type { ReactNativeOptions } from '../options'; type DangerTypesWithoutCallSignature = diff --git a/packages/core/src/js/utils/sentryeventemitter.ts b/packages/core/src/js/utils/sentryeventemitter.ts index 55362df3cd..a066e457dd 100644 --- a/packages/core/src/js/utils/sentryeventemitter.ts +++ b/packages/core/src/js/utils/sentryeventemitter.ts @@ -1,7 +1,6 @@ import { logger } from '@sentry/core'; import type { EmitterSubscription, NativeModule } from 'react-native'; import { NativeEventEmitter } from 'react-native'; - import { getRNSentryModule } from '../wrapper'; export const NewFrameEventName = 'rn_sentry_new_frame'; diff --git a/packages/core/src/js/utils/sentryeventemitterfallback.ts b/packages/core/src/js/utils/sentryeventemitterfallback.ts index 74f1ab58e8..a10b62117b 100644 --- a/packages/core/src/js/utils/sentryeventemitterfallback.ts +++ b/packages/core/src/js/utils/sentryeventemitterfallback.ts @@ -1,5 +1,4 @@ import { logger, timestampInSeconds } from '@sentry/core'; - import { NATIVE } from '../wrapper'; import type { NewFrameEvent, SentryEventEmitter } from './sentryeventemitter'; import { createSentryEventEmitter, NewFrameEventName } from './sentryeventemitter'; diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index 04c854984a..74b36eaadf 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -1,8 +1,8 @@ import type { InternalGlobal } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import type { ErrorUtils } from 'react-native/types'; - import type { ExpoGlobalObject } from './expoglobalobject'; + export interface HermesPromiseRejectionTrackingOptions { allRejections: boolean; onUnhandled: (id: string, error: unknown) => void; @@ -33,11 +33,18 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { nativePerformanceNow?: () => number; TextEncoder?: TextEncoder; alert?: (message: string) => void; + SENTRY_RELEASE?: { + /** Used by Sentry Webpack Plugin, not used by RN, only to silence TS */ + id?: string; + name?: string; + version?: string; + }; } type TextEncoder = { - new (): TextEncoder; - encode(input?: string): Uint8Array; + new (): { + encode(input?: string): Uint8Array; + }; }; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/core/src/js/vendor/base64-js/fromByteArray.ts b/packages/core/src/js/vendor/base64-js/fromByteArray.ts index 51c046b0a4..11c771f1d5 100644 --- a/packages/core/src/js/vendor/base64-js/fromByteArray.ts +++ b/packages/core/src/js/vendor/base64-js/fromByteArray.ts @@ -28,10 +28,12 @@ const lookup: string[] = []; const code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; for (let i = 0, len = code.length; i < len; ++i) { + // @ts-expect-error lookup[i] = code[i]; } function tripletToBase64(num: number): string { + // @ts-expect-error return lookup[(num >> 18) & 0x3f] + lookup[(num >> 12) & 0x3f] + lookup[(num >> 6) & 0x3f] + lookup[num & 0x3f]; } @@ -39,6 +41,7 @@ function encodeChunk(uint8: Uint8Array | number[], start: number, end: number): let tmp; const output = []; for (let i = start; i < end; i += 3) { + // @ts-expect-error tmp = ((uint8[i] << 16) & 0xff0000) + ((uint8[i + 1] << 8) & 0xff00) + (uint8[i + 2] & 0xff); output.push(tripletToBase64(tmp)); } @@ -63,9 +66,12 @@ export function base64StringFromByteArray(uint8: Uint8Array | number[]): string // pad the end with zeros, but make sure to not forget the extra bytes if (extraBytes === 1) { tmp = uint8[len - 1]; + // @ts-expect-error parts.push(`${lookup[tmp >> 2] + lookup[(tmp << 4) & 0x3f]}==`); } else if (extraBytes === 2) { + // @ts-expect-error tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + // @ts-expect-error parts.push(`${lookup[tmp >> 10] + lookup[(tmp >> 4) & 0x3f] + lookup[(tmp << 2) & 0x3f]}=`); } diff --git a/packages/core/src/js/version.ts b/packages/core/src/js/version.ts index b9f54c47f5..eba7e50c1b 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.19.0'; +export const SDK_VERSION = '7.0.0-rc.1'; diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index fdd04e8ac8..955c5245d7 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -6,12 +6,12 @@ import type { EnvelopeItem, Event, Package, + Primitive, SeverityLevel, User, } from '@sentry/core'; import { logger, normalize, SentryError } from '@sentry/core'; import { NativeModules, Platform } from 'react-native'; - import { isHardCrash } from './misc'; import type { NativeAppStartResponse, @@ -27,17 +27,18 @@ import type * as Hermes from './profiling/hermes'; import type { NativeAndroidProfileEvent, NativeProfileEvent } from './profiling/nativeTypes'; import type { MobileReplayOptions } from './replay/mobilereplay'; import type { RequiredKeysUser } from './user'; +import { encodeUTF8 } from './utils/encode'; import { isTurboModuleEnabled } from './utils/environment'; import { convertToNormalizedObject } from './utils/normalize'; import { ReactNativeLibraries } from './utils/rnlibraries'; -import { base64StringFromByteArray, utf8ToBytes } from './vendor'; +import { base64StringFromByteArray } from './vendor'; /** * Returns the RNSentry module. Dynamically resolves if NativeModule or TurboModule is used. */ export function getRNSentryModule(): Spec | undefined { return isTurboModuleEnabled() - ? ReactNativeLibraries.TurboModuleRegistry && ReactNativeLibraries.TurboModuleRegistry.get('RNSentry') + ? ReactNativeLibraries.TurboModuleRegistry?.get('RNSentry') : NativeModules.RNSentry; } @@ -52,6 +53,8 @@ export interface Screenshot { export type NativeSdkOptions = Partial & { devServerUrl: string | undefined; defaultSidecarUrl: string | undefined; + ignoreErrorsStr?: string[] | undefined; + ignoreErrorsRegex?: string[] | undefined; } & { mobileReplayOptions: MobileReplayOptions | undefined; }; @@ -64,6 +67,7 @@ interface SentryNativeWrapper { _NativeClientError: Error; _DisabledNativeError: Error; + _setPrimitiveProcessor: (processor: (value: Primitive) => void) => void; _processItem(envelopeItem: EnvelopeItem): EnvelopeItem; _processLevels(event: Event): Event; _processLevel(level: SeverityLevel): SeverityLevel; @@ -80,6 +84,7 @@ interface SentryNativeWrapper { fetchNativeRelease(): PromiseLike; fetchNativeDeviceContexts(): PromiseLike; + fetchNativeLogAttributes(): Promise; fetchNativeAppStart(): PromiseLike; fetchNativeFrames(): PromiseLike; fetchNativeSdkInfo(): PromiseLike; @@ -93,7 +98,7 @@ interface SentryNativeWrapper { clearBreadcrumbs(): void; setExtra(key: string, extra: unknown): void; setUser(user: User | null): void; - setTag(key: string, value: string): void; + setTag(key: string, value?: string): void; nativeCrash(): void; @@ -127,9 +132,11 @@ interface SentryNativeWrapper { setActiveSpanId(spanId: string): void; encodeToBase64(data: Uint8Array): Promise; + + primitiveProcessor(value: Primitive): string; } -const EOL = utf8ToBytes('\n'); +const EOL = encodeUTF8('\n'); /** * Our internal interface for calling native functions @@ -166,7 +173,7 @@ export const NATIVE: SentryNativeWrapper = { const [envelopeHeader, envelopeItems] = envelope; const headerString = JSON.stringify(envelopeHeader); - const headerBytes = utf8ToBytes(headerString); + const headerBytes = encodeUTF8(headerString); let envelopeBytes: Uint8Array = new Uint8Array(headerBytes.length + EOL.length); envelopeBytes.set(headerBytes); envelopeBytes.set(EOL, headerBytes.length); @@ -179,14 +186,14 @@ export const NATIVE: SentryNativeWrapper = { let bytesPayload: number[] | Uint8Array | undefined; if (typeof itemPayload === 'string') { bytesContentType = 'text/plain'; - bytesPayload = utf8ToBytes(itemPayload); + bytesPayload = encodeUTF8(itemPayload); } else if (itemPayload instanceof Uint8Array) { bytesContentType = typeof itemHeader.content_type === 'string' ? itemHeader.content_type : 'application/octet-stream'; bytesPayload = itemPayload; } else { - bytesContentType = 'application/json'; - bytesPayload = utf8ToBytes(JSON.stringify(itemPayload)); + bytesContentType = 'application/vnd.sentry.items.log+json'; + bytesPayload = encodeUTF8(JSON.stringify(itemPayload)); if (!hardCrashed) { hardCrashed = isHardCrash(itemPayload); } @@ -197,7 +204,7 @@ export const NATIVE: SentryNativeWrapper = { (itemHeader as BaseEnvelopeItemHeaders).length = bytesPayload.length; const serializedItemHeader = JSON.stringify(itemHeader); - const bytesItemHeader = utf8ToBytes(serializedItemHeader); + const bytesItemHeader = encodeUTF8(serializedItemHeader); const newBytes = new Uint8Array( envelopeBytes.length + bytesItemHeader.length + EOL.length + bytesPayload.length + EOL.length, ); @@ -251,10 +258,22 @@ export const NATIVE: SentryNativeWrapper = { if (!this._isModuleLoaded(RNSentry)) { throw this._NativeClientError; } + const ignoreErrorsStr = options.ignoreErrors?.filter(item => typeof item === 'string') as string[] | undefined; + const ignoreErrorsRegex = options.ignoreErrors + ?.filter(item => item instanceof RegExp) + .map(item => (item as RegExp).source) as string[] | undefined; + + if (ignoreErrorsStr && ignoreErrorsStr.length > 0) { + options.ignoreErrorsStr = ignoreErrorsStr; + } + if (ignoreErrorsRegex && ignoreErrorsRegex.length > 0) { + options.ignoreErrorsRegex = ignoreErrorsRegex; + } // filter out all the options that would crash native. /* eslint-disable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */ - const { beforeSend, beforeBreadcrumb, beforeSendTransaction, integrations, ...filteredOptions } = options; + const { beforeSend, beforeBreadcrumb, beforeSendTransaction, integrations, ignoreErrors, ...filteredOptions } = + options; /* eslint-enable @typescript-eslint/unbound-method,@typescript-eslint/no-unused-vars */ const nativeIsReady = await RNSentry.initNativeSdk(filteredOptions); @@ -264,6 +283,19 @@ export const NATIVE: SentryNativeWrapper = { return nativeIsReady; }, + /** + * Fetches the attributes to be set into logs from Native + */ + async fetchNativeLogAttributes(): Promise { + if (!this.enableNative) { + throw this._DisabledNativeError; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + return RNSentry.fetchNativeLogAttributes(); + }, /** * Fetches the release from native */ @@ -382,7 +414,7 @@ export const NATIVE: SentryNativeWrapper = { * @param key string * @param value string */ - setTag(key: string, value: string): void { + setTag(key: string, value?: string): void { if (!this.enableNative) { return; } @@ -731,7 +763,7 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.popTimeToDisplayFor(key); } catch (error) { logger.error('Error:', error); - return null; + return Promise.resolve(null); } }, @@ -763,6 +795,10 @@ export const NATIVE: SentryNativeWrapper = { } }, + primitiveProcessor: function (value: Primitive): string { + return value as string; + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. @@ -808,7 +844,6 @@ export const NATIVE: SentryNativeWrapper = { * @param event * @returns Event with more widely supported Severity level strings */ - _processLevels(event: Event): Event { const processed: Event = { ...event, @@ -827,7 +862,6 @@ export const NATIVE: SentryNativeWrapper = { * @param level * @returns More widely supported Severity level strings */ - _processLevel(level: SeverityLevel): SeverityLevel { if (level == ('log' as SeverityLevel)) { return 'debug' as SeverityLevel; @@ -842,6 +876,10 @@ export const NATIVE: SentryNativeWrapper = { return !!module; }, + _setPrimitiveProcessor: function (processor: (value: Primitive) => any): void { + this.primitiveProcessor = processor; + }, + _DisabledNativeError: new SentryError('Native is disabled'), _NativeClientError: new SentryError("Native Client is not available, can't start on native."), diff --git a/packages/core/test/breadcrumb.test.ts b/packages/core/test/breadcrumb.test.ts index e0b7db4c60..9f1609727b 100644 --- a/packages/core/test/breadcrumb.test.ts +++ b/packages/core/test/breadcrumb.test.ts @@ -1,5 +1,4 @@ import type { Breadcrumb } from '@sentry/core'; - import { breadcrumbFromObject } from '../src/js/breadcrumb'; describe('Breadcrumb', () => { diff --git a/packages/core/test/client.test.ts b/packages/core/test/client.test.ts index 8cb4217356..14c35b533e 100644 --- a/packages/core/test/client.test.ts +++ b/packages/core/test/client.test.ts @@ -1,11 +1,20 @@ -import * as mockedtimetodisplaynative from './tracing/mockedtimetodisplaynative'; -jest.mock('../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); - import { defaultStackParser } from '@sentry/browser'; -import type { Envelope, Event, Outcome, Transport, TransportMakeRequestResponse } from '@sentry/core'; -import { rejectedSyncPromise, SentryError } from '@sentry/core'; +import type { + Envelope, + Event, + Outcome, + SessionAggregates, + Transport, + TransportMakeRequestResponse, +} from '@sentry/core'; +import { + addAutoIpAddressToSession, + addAutoIpAddressToUser, + makeSession, + rejectedSyncPromise, + SentryError, +} from '@sentry/core'; import * as RN from 'react-native'; - import { ReactNativeClient } from '../src/js/client'; import type { ReactNativeClientOptions } from '../src/js/options'; import { NativeTransport } from '../src/js/transports/native'; @@ -22,6 +31,9 @@ import { getMockUserFeedback, getSyncPromiseRejectOnFirstCall, } from './testutils'; +import * as mockedtimetodisplaynative from './tracing/mockedtimetodisplaynative'; + +jest.mock('../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); interface MockedReactNative { NativeModules: { @@ -625,6 +637,206 @@ describe('Tests ReactNativeClient', () => { client.recordDroppedEvent('before_send', 'error'); } }); + + describe('ipAddress', () => { + let mockTransportSend: jest.Mock; + let client: ReactNativeClient; + + beforeEach(() => { + mockTransportSend = jest.fn(() => Promise.resolve()); + client = new ReactNativeClient({ + ...DEFAULT_OPTIONS, + dsn: EXAMPLE_DSN, + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: true, + }); + }); + + test('preserves ip_address null', () => { + client.captureEvent({ + user: { + ip_address: null, + }, + }); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].user).toEqual( + expect.objectContaining({ ip_address: null }), + ); + }); + + test('preserves ip_address value if set', () => { + client.captureEvent({ + user: { + ip_address: '203.45.167.89', + }, + }); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].user).toEqual( + expect.objectContaining({ ip_address: '203.45.167.89' }), + ); + }); + + test('adds ip_address {{auto}} to user if set to undefined', () => { + client.captureEvent({ + user: { + ip_address: undefined, + }, + }); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].user).toEqual( + expect.objectContaining({ ip_address: '{{auto}}' }), + ); + }); + + test('adds ip_address {{auto}} to user if not set', () => { + client.captureEvent({ + user: {}, + }); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].user).toEqual( + expect.objectContaining({ ip_address: '{{auto}}' }), + ); + }); + + test('adds ip_address {{auto}} to undefined user', () => { + client.captureEvent({}); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].user).toEqual( + expect.objectContaining({ ip_address: '{{auto}}' }), + ); + }); + + test('does not add ip_address {{auto}} to undefined user if sendDefaultPii is false', () => { + const { client, onSpy } = createClientWithSpy({ + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: false, + }); + + client.captureEvent({}); + + expect(onSpy).not.toHaveBeenCalledWith('postprocessEvent', addAutoIpAddressToUser); + expect( + mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].user?.ip_address, + ).toBeUndefined(); + }); + + test('uses ip address hooks if sendDefaultPii is true', () => { + const { onSpy } = createClientWithSpy({ + sendDefaultPii: true, + }); + + expect(onSpy).toHaveBeenCalledWith('postprocessEvent', addAutoIpAddressToUser); + expect(onSpy).toHaveBeenCalledWith('beforeSendSession', addAutoIpAddressToSession); + }); + + test('does not add ip_address {{auto}} to session if sendDefaultPii is false', () => { + const { client, onSpy } = createClientWithSpy({ + release: 'test', // required for sessions to be sent + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: false, + }); + + const session = makeSession(); + session.ipAddress = undefined; + client.captureSession(session); + + expect(onSpy).not.toHaveBeenCalledWith('beforeSendSession', addAutoIpAddressToSession); + expect( + mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].attrs.ip_address, + ).toBeUndefined(); + }); + + test('does not add ip_address {{auto}} to session aggregate if sendDefaultPii is false', () => { + const { client, onSpy } = createClientWithSpy({ + release: 'test', // required for sessions to be sent + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: false, + }); + + const session: SessionAggregates = { + aggregates: [], + }; + client.sendSession(session); + + expect(onSpy).not.toHaveBeenCalledWith('beforeSendSession', addAutoIpAddressToSession); + expect( + mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].attrs.ip_address, + ).toBeUndefined(); + }); + + test('does not overwrite session aggregate ip_address if already set', () => { + const { client } = createClientWithSpy({ + release: 'test', // required for sessions to be sent + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: true, + }); + + const session: SessionAggregates = { + aggregates: [], + attrs: { + ip_address: '123.45.67.89', + }, + }; + client.sendSession(session); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].attrs.ip_address).toBe( + '123.45.67.89', + ); + }); + + test('does add ip_address {{auto}} to session if sendDefaultPii is true', () => { + const { client } = createClientWithSpy({ + release: 'test', // required for sessions to be sent + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: true, + }); + + const session = makeSession(); + session.ipAddress = undefined; + client.captureSession(session); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].attrs.ip_address).toBe( + '{{auto}}', + ); + }); + + test('does not overwrite session ip_address if already set', () => { + const { client } = createClientWithSpy({ + release: 'test', // required for sessions to be sent + transport: () => ({ + send: mockTransportSend, + flush: jest.fn(), + }), + sendDefaultPii: true, + }); + + const session = makeSession(); + session.ipAddress = '123.45.67.89'; + client.captureSession(session); + + expect(mockTransportSend.mock.calls[0][firstArg][envelopeItems][0][envelopeItemPayload].attrs.ip_address).toBe( + '123.45.67.89', + ); + }); + }); }); function mockedOptions(options: Partial): ReactNativeClientOptions { @@ -638,3 +850,23 @@ function mockedOptions(options: Partial): ReactNativeC ...options, }; } + +function createClientWithSpy(options: Partial) { + const onSpy = jest.fn(); + class SpyClient extends ReactNativeClient { + public on(hook: string, callback: unknown): () => void { + onSpy(hook, callback); + // @ts-expect-error - the public interface doesn't allow string and unknown + return super.on(hook, callback); + } + } + + return { + client: new SpyClient({ + ...DEFAULT_OPTIONS, + dsn: EXAMPLE_DSN, + ...options, + }), + onSpy, + }; +} diff --git a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts index 6b513afd41..a359f2b23b 100644 --- a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts +++ b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts @@ -1,5 +1,4 @@ import { withAppBuildGradle, withProjectBuildGradle } from '@expo/config-plugins'; - import { warnOnce } from '../../plugin/src/utils'; import type { SentryAndroidGradlePluginOptions } from '../../plugin/src/withSentryAndroidGradlePlugin'; import { @@ -87,7 +86,7 @@ describe('withSentryAndroidGradlePlugin', () => { }); it('warnOnce if failed to modify build.gradle', () => { - const invalidBuildGradle = `android {}`; + const invalidBuildGradle = 'android {}'; const options: SentryAndroidGradlePluginOptions = { enableAndroidGradlePlugin: true }; (withProjectBuildGradle as jest.Mock).mockImplementation((config, callback) => { diff --git a/packages/core/test/feedback.test.ts b/packages/core/test/feedback.test.ts index 6b1831934d..ca75063402 100644 --- a/packages/core/test/feedback.test.ts +++ b/packages/core/test/feedback.test.ts @@ -9,7 +9,6 @@ import { withIsolationScope, withScope, } from '@sentry/core'; - import { getDefaultTestClientOptions, TestClient } from './mocks/client'; describe('captureFeedback', () => { @@ -240,17 +239,17 @@ describe('captureFeedback', () => { const mockTransport = jest.spyOn(client.getTransport()!, 'send'); const traceId = '4C79F60C11214EB38604F4AE0781BFB2'; - const spanId = 'FA90FDEAD5F74052'; + const parentSpanId = 'FA90FDEAD5F74052'; const dsc = { trace_id: traceId, - span_id: spanId, sampled: 'true', }; getCurrentScope().setPropagationContext({ traceId, - spanId, + parentSpanId, dsc, + sampleRand: 1, }); const eventId = captureFeedback({ @@ -264,7 +263,7 @@ describe('captureFeedback', () => { expect(mockTransport).toHaveBeenCalledWith([ { event_id: eventId, - sent_at: expect.any(String), + sent_at: expect.toBeDateString(), }, [ [ @@ -274,7 +273,8 @@ describe('captureFeedback', () => { contexts: { trace: { trace_id: traceId, - span_id: spanId, + parent_span_id: parentSpanId, + span_id: expect.any(String), }, feedback: { message: 'test', @@ -297,7 +297,7 @@ describe('captureFeedback', () => { getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', enableSend: true, - enableTracing: true, + tracesSampleRate: 1.0, // We don't care about transactions here... beforeSendTransaction() { return null; @@ -322,12 +322,12 @@ describe('captureFeedback', () => { expect(typeof eventId).toBe('string'); expect(span).toBeDefined(); - const { spanId, traceId } = span!.spanContext(); + const traceId = span!.spanContext().traceId; expect(mockTransport).toHaveBeenCalledWith([ { event_id: eventId, - sent_at: expect.any(String), + sent_at: expect.toBeDateString(), }, [ [ @@ -337,7 +337,7 @@ describe('captureFeedback', () => { contexts: { trace: { trace_id: traceId, - span_id: spanId, + span_id: expect.any(String), }, feedback: { message: 'test', @@ -360,7 +360,7 @@ describe('captureFeedback', () => { getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', enableSend: true, - enableTracing: true, + tracesSampleRate: 1.0, // We don't care about transactions here... beforeSendTransaction() { return null; diff --git a/packages/core/test/feedback/FeedbackButton.test.tsx b/packages/core/test/feedback/FeedbackButton.test.tsx index 579bccc7ca..db2dd025a4 100644 --- a/packages/core/test/feedback/FeedbackButton.test.tsx +++ b/packages/core/test/feedback/FeedbackButton.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native'; import * as React from 'react'; - import { FeedbackButton } from '../../src/js/feedback/FeedbackButton'; import type { FeedbackButtonProps, FeedbackButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; import { showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; diff --git a/packages/core/test/feedback/FeedbackWidget.test.tsx b/packages/core/test/feedback/FeedbackWidget.test.tsx index da7b13f585..1e424cde77 100644 --- a/packages/core/test/feedback/FeedbackWidget.test.tsx +++ b/packages/core/test/feedback/FeedbackWidget.test.tsx @@ -2,7 +2,6 @@ import { captureFeedback, getClient, setCurrentClient } from '@sentry/core'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import * as React from 'react'; import { Alert } from 'react-native'; - import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; import type { FeedbackWidgetProps, FeedbackWidgetStyles, ImagePicker } from '../../src/js/feedback/FeedbackWidget.types'; import { MOBILE_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/integration'; @@ -384,7 +383,7 @@ describe('FeedbackWidget', () => { it('calls launchImageLibraryAsync when the expo-image-picker library is integrated', async () => { const mockLaunchImageLibrary = jest.fn().mockResolvedValue({ - assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }], + assets: [{ fileName: 'mock-image.jpg', uri: 'file:///mock/path/image.jpg' }], }); const mockImagePicker: jest.Mocked = { launchImageLibraryAsync: mockLaunchImageLibrary, @@ -401,7 +400,7 @@ describe('FeedbackWidget', () => { it('calls launchImageLibrary when the react-native-image-picker library is integrated', async () => { const mockLaunchImageLibrary = jest.fn().mockResolvedValue({ - assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }], + assets: [{ fileName: 'mock-image.jpg', uri: 'file:///mock/path/image.jpg' }], }); const mockImagePicker: jest.Mocked = { launchImageLibrary: mockLaunchImageLibrary, diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx index 21e10bff1e..bfb67c91d1 100644 --- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -2,7 +2,6 @@ import { getClient, logger, setCurrentClient } from '@sentry/core'; import { render } from '@testing-library/react-native'; import * as React from 'react'; import { Appearance, Text } from 'react-native'; - import { defaultConfiguration } from '../../src/js/feedback/defaults'; import { hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider'; @@ -116,7 +115,7 @@ describe('FeedbackWidgetManager', () => { showFeedbackWidget(); - expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackWidget requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackWidget()'.`); + expect(consoleWarnSpy).toHaveBeenLastCalledWith('[Sentry] FeedbackWidget requires \'Sentry.wrap(RootComponent)\' to be called before \'showFeedbackWidget()\'.'); }); it('showFeedbackWidget does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { @@ -196,7 +195,7 @@ describe('FeedbackButtonManager', () => { it('showFeedbackButton warns about missing feedback provider', () => { showFeedbackButton(); - expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackButton requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackButton()'.`); + expect(consoleWarnSpy).toHaveBeenLastCalledWith('[Sentry] FeedbackButton requires \'Sentry.wrap(RootComponent)\' to be called before \'showFeedbackButton()\'.'); }); it('showFeedbackButton does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { diff --git a/packages/core/test/feedback/ScreenshotButton.test.tsx b/packages/core/test/feedback/ScreenshotButton.test.tsx index 2419860d1c..0bacceb208 100644 --- a/packages/core/test/feedback/ScreenshotButton.test.tsx +++ b/packages/core/test/feedback/ScreenshotButton.test.tsx @@ -2,7 +2,6 @@ import { getClient, setCurrentClient } from '@sentry/core'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import * as React from 'react'; import { Alert, Text } from 'react-native'; - import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; import type { ScreenshotButtonProps, ScreenshotButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; import { resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager, showFeedbackButton } from '../../src/js/feedback/FeedbackWidgetManager'; diff --git a/packages/core/test/integrations/appRegistry.test.ts b/packages/core/test/integrations/appRegistry.test.ts index 535616c378..44e7d6a3b8 100644 --- a/packages/core/test/integrations/appRegistry.test.ts +++ b/packages/core/test/integrations/appRegistry.test.ts @@ -1,5 +1,4 @@ import { getOriginalFunction } from '@sentry/core'; - import { appRegistryIntegration } from '../../src/js/integrations/appRegistry'; import * as Environment from '../../src/js/utils/environment'; import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries'; diff --git a/packages/core/test/integrations/breadcrumbs.test.ts b/packages/core/test/integrations/breadcrumbs.test.ts new file mode 100644 index 0000000000..e7105667fe --- /dev/null +++ b/packages/core/test/integrations/breadcrumbs.test.ts @@ -0,0 +1,89 @@ +import { breadcrumbsIntegration as browserBreadcrumbsIntegration } from '@sentry/browser'; +import { breadcrumbsIntegration } from '../../src/js/integrations/breadcrumbs'; +import * as environment from '../../src/js/utils/environment'; + +jest.mock('@sentry/browser', () => ({ + breadcrumbsIntegration: jest.fn(), +})); + +describe('breadcrumbsIntegration', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('passes React Native defaults to browserBreadcrumbsIntegration', () => { + jest.spyOn(environment, 'isWeb').mockReturnValue(false); + + breadcrumbsIntegration(); + + expect(browserBreadcrumbsIntegration).toHaveBeenCalledWith({ + xhr: true, + console: true, + sentry: true, + dom: false, // DOM is not available in React Native + fetch: false, // fetch is built on XMLHttpRequest in React Native + history: false, // history is not available in React Native + }); + }); + + it('passes web defaults to browserBreadcrumbsIntegration when isWeb returns true', () => { + jest.spyOn(environment, 'isWeb').mockReturnValue(true); + + breadcrumbsIntegration(); + + expect(browserBreadcrumbsIntegration).toHaveBeenCalledWith({ + // Everything is enabled by default on web + xhr: true, + console: true, + sentry: true, + dom: true, + fetch: true, + history: true, + }); + }); + + it('respects custom options React Native options', () => { + jest.spyOn(environment, 'isWeb').mockReturnValue(false); + + breadcrumbsIntegration({ + xhr: false, + console: false, + sentry: false, + dom: {}, // Integration should not let user enable DOM breadcrumbs on React Native + fetch: true, // If user enables it, we should log fetch requests + history: true, // Integration should not let user enable history breadcrumbs on React Native + }); + + expect(browserBreadcrumbsIntegration).toHaveBeenCalledWith({ + xhr: false, + console: false, + sentry: false, + dom: false, + fetch: true, + history: false, + }); + }); + + it('respects custom options when isWeb returns true', () => { + jest.spyOn(environment, 'isWeb').mockReturnValue(true); + + breadcrumbsIntegration({ + // Everything can be disabled on web + xhr: false, + console: false, + sentry: false, + dom: false, + fetch: false, + history: false, + }); + + expect(browserBreadcrumbsIntegration).toHaveBeenCalledWith({ + xhr: false, + console: false, + sentry: false, + dom: false, + fetch: false, + history: false, + }); + }); +}); diff --git a/packages/core/test/integrations/debugsymbolicator.test.ts b/packages/core/test/integrations/debugsymbolicator.test.ts index 9ed5728319..265780d01f 100644 --- a/packages/core/test/integrations/debugsymbolicator.test.ts +++ b/packages/core/test/integrations/debugsymbolicator.test.ts @@ -1,7 +1,4 @@ -jest.mock('../../src/js/integrations/debugsymbolicatorutils'); - import type { Client, Event, EventHint, StackFrame } from '@sentry/core'; - import { debugSymbolicatorIntegration } from '../../src/js/integrations/debugsymbolicator'; import { fetchSourceContext, @@ -11,6 +8,8 @@ import { } from '../../src/js/integrations/debugsymbolicatorutils'; import type * as ReactNative from '../../src/js/vendor/react-native'; +jest.mock('../../src/js/integrations/debugsymbolicatorutils'); + async function processEvent(mockedEvent: Event, mockedHint: EventHint): Promise { return debugSymbolicatorIntegration().processEvent!(mockedEvent, mockedHint, {} as Client); } diff --git a/packages/core/test/integrations/devicecontext.test.ts b/packages/core/test/integrations/devicecontext.test.ts index 014b4939eb..2689582ee4 100644 --- a/packages/core/test/integrations/devicecontext.test.ts +++ b/packages/core/test/integrations/devicecontext.test.ts @@ -1,5 +1,4 @@ import type { Client, Event, EventHint, SeverityLevel } from '@sentry/core'; - import { deviceContextIntegration } from '../../src/js/integrations/devicecontext'; import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; import { NATIVE } from '../../src/js/wrapper'; diff --git a/packages/core/test/integrations/eventorigin.test.ts b/packages/core/test/integrations/eventorigin.test.ts index dd3e0cbd91..3b5ed14c1c 100644 --- a/packages/core/test/integrations/eventorigin.test.ts +++ b/packages/core/test/integrations/eventorigin.test.ts @@ -1,5 +1,4 @@ import type { Client } from '@sentry/core'; - import { eventOriginIntegration } from '../../src/js/integrations/eventorigin'; describe('Event Origin', () => { diff --git a/packages/core/test/integrations/expocontext.test.ts b/packages/core/test/integrations/expocontext.test.ts index 205fc35b57..47381e8926 100644 --- a/packages/core/test/integrations/expocontext.test.ts +++ b/packages/core/test/integrations/expocontext.test.ts @@ -1,5 +1,4 @@ import { type Client, type Event, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; - import { expoContextIntegration, getExpoUpdatesContext, diff --git a/packages/core/test/integrations/integrationsexecutionorder.test.ts b/packages/core/test/integrations/integrationsexecutionorder.test.ts index 08b4a4f5a6..09c1f3fb65 100644 --- a/packages/core/test/integrations/integrationsexecutionorder.test.ts +++ b/packages/core/test/integrations/integrationsexecutionorder.test.ts @@ -1,10 +1,10 @@ import * as mockWrapper from '../mockWrapper'; + jest.mock('../../src/js/wrapper', () => mockWrapper); jest.mock('../../src/js/utils/environment'); import { defaultStackParser } from '@sentry/browser'; import type { Integration } from '@sentry/core'; - import { ReactNativeClient } from '../../src/js/client'; import { getDefaultIntegrations } from '../../src/js/integrations/default'; import type { ReactNativeClientOptions } from '../../src/js/options'; diff --git a/packages/core/test/integrations/logEnricherIntegration.test.ts b/packages/core/test/integrations/logEnricherIntegration.test.ts new file mode 100644 index 0000000000..fc75ecaa3b --- /dev/null +++ b/packages/core/test/integrations/logEnricherIntegration.test.ts @@ -0,0 +1,407 @@ +import type { Client, Log } from '@sentry/core'; +import { logger } from '@sentry/core'; +import { logEnricherIntegration } from '../../src/js/integrations/logEnricherIntegration'; +import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; +import { NATIVE } from '../../src/js/wrapper'; + +// Mock the NATIVE wrapper +jest.mock('../../src/js/wrapper'); +jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), + logger: { + log: jest.fn(), + }, +})); + +const mockLogger = logger as jest.Mocked; + +function on_beforeCaptureLogCount(client: jest.Mocked) { + const beforeCaptureLogCalls = client.on.mock.calls.filter( + ([eventName, _]) => eventName.toString() === 'beforeCaptureLog', + ); + + return beforeCaptureLogCalls.length; +} + +describe('LogEnricher Integration', () => { + let mockClient: jest.Mocked; + let mockOn: jest.Mock; + let mockFetchNativeLogAttributes: jest.Mock; + + const triggerAfterInit = () => { + const afterInitCallback = mockOn.mock.calls.find(call => call[0] === 'afterInit')?.[1] as (() => void) | undefined; + expect(afterInitCallback).toBeDefined(); + afterInitCallback!(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockOn = jest.fn(); + mockFetchNativeLogAttributes = jest.fn(); + + mockClient = { + on: mockOn, + } as unknown as jest.Mocked; + + (NATIVE as jest.Mocked).fetchNativeLogAttributes = mockFetchNativeLogAttributes; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('setup', () => { + it('should set up the integration and register beforeCaptureLog handler after afterInit event', async () => { + const integration = logEnricherIntegration(); + + // Mock successful native response + const mockNativeResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Apple', + model: 'iPhone 14', + family: 'iPhone', + } as Record, + os: { + name: 'iOS', + version: '16.0', + } as Record, + release: '1.0.0' as unknown as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse); + + integration.setup(mockClient); + + // Initially, only afterInit handler should be registered + expect(mockOn).toHaveBeenCalledWith('afterInit', expect.any(Function)); + expect(mockOn).toHaveBeenCalledTimes(1); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); + expect(mockFetchNativeLogAttributes).toHaveBeenCalledTimes(1); + }); + + it('should handle native fetch failure gracefully', async () => { + const integration = logEnricherIntegration(); + + const errorMessage = 'Native fetch failed'; + mockFetchNativeLogAttributes.mockRejectedValue(new Error(errorMessage)); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), + ); + expect(mockOn).toHaveBeenCalledTimes(1); + }); + + it('should handle null response from native layer', async () => { + const integration = logEnricherIntegration(); + + mockFetchNativeLogAttributes.mockResolvedValue(null); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(1); + }); + }); + + describe('log processing', () => { + let logHandler: (log: Log) => void; + let mockLog: Log; + + beforeEach(async () => { + const integration = logEnricherIntegration(); + + const mockNativeResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Apple', + model: 'iPhone 14', + family: 'iPhone', + } as Record, + os: { + name: 'iOS', + version: '16.0', + } as Record, + release: '1.0.0' as unknown as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse); + + integration.setup(mockClient); + + // Simulate the afterInit event + triggerAfterInit(); + + // Wait for the async operations to complete + await jest.runAllTimersAsync(); + + // Extract the log handler + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + logHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + mockLog = { + message: 'Test log message', + level: 'info', + attributes: {}, + }; + }); + + it('should enrich log with device attributes', () => { + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + }); + }); + + it('should preserve existing log attributes', () => { + mockLog.attributes = { + existing: 'value', + 'custom.attr': 'custom-value', + }; + + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + existing: 'value', + 'custom.attr': 'custom-value', + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + }); + }); + + it('should handle log without attributes', () => { + mockLog.attributes = undefined; + + logHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'device.model': 'iPhone 14', + 'device.family': 'iPhone', + 'os.name': 'iOS', + 'os.version': '16.0', + 'sentry.release': '1.0.0', + }); + }); + + it('should only add attributes that exist in cache', async () => { + const integration = logEnricherIntegration(); + + const partialNativeResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Apple', + // model and family missing + } as Record, + os: { + name: 'iOS', + // version missing + } as Record, + // release missing + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(partialNativeResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const newLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + newLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Apple', + 'os.name': 'iOS', + }); + }); + + it('should not register beforeCaptureLog handler when native fetch fails', async () => { + const integration = logEnricherIntegration(); + + mockFetchNativeLogAttributes.mockRejectedValue(new Error('Failed')); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), + ); + + // Default client count. + expect(on_beforeCaptureLogCount(mockClient)).toBe(1); + }); + + it('should handle empty contexts in native response', async () => { + const integration = logEnricherIntegration(); + + const emptyNativeResponse: NativeDeviceContextsResponse = { + contexts: {}, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(emptyNativeResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const emptyLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + emptyLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({}); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(2); + }); + + it('should handle partial device context', async () => { + const integration = logEnricherIntegration(); + + const partialDeviceResponse: NativeDeviceContextsResponse = { + contexts: { + device: { + brand: 'Samsung', + model: 'Galaxy S21', + // family missing + } as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(partialDeviceResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const partialLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + partialLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'device.brand': 'Samsung', + 'device.model': 'Galaxy S21', + }); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(2); + }); + + it('should handle partial OS context', async () => { + const integration = logEnricherIntegration(); + + const partialOsResponse: NativeDeviceContextsResponse = { + contexts: { + os: { + name: 'Android', + // version missing + } as Record, + }, + }; + + mockFetchNativeLogAttributes.mockResolvedValue(partialOsResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog'); + expect(beforeCaptureLogCall).toBeDefined(); + const partialLogHandler = beforeCaptureLogCall![1] as (log: Log) => void; + + partialLogHandler(mockLog); + + expect(mockLog.attributes).toEqual({ + 'os.name': 'Android', + }); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(2); + }); + }); + + describe('error handling', () => { + it('should handle errors', async () => { + const integration = logEnricherIntegration(); + + mockFetchNativeLogAttributes.mockRejectedValue(new Error('Failed to Initialize')); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('[LOGS]: Failed to prepare attributes from Native Layer'), + ); + expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Failed to Initialize')); + + expect(on_beforeCaptureLogCount(mockClient)).toBe(0); + }); + + it('should handle malformed native response', async () => { + const integration = logEnricherIntegration(); + + const malformedResponse = { + someUnexpectedKey: 'value', + }; + + mockFetchNativeLogAttributes.mockResolvedValue(malformedResponse as NativeDeviceContextsResponse); + + integration.setup(mockClient); + + triggerAfterInit(); + + await jest.runAllTimersAsync(); + + expect(mockOn).toHaveBeenCalledWith('beforeCaptureLog', expect.any(Function)); + }); + }); +}); diff --git a/packages/core/test/integrations/modulesloader.test.ts b/packages/core/test/integrations/modulesloader.test.ts index c6fb43bb2f..50b99e66b3 100644 --- a/packages/core/test/integrations/modulesloader.test.ts +++ b/packages/core/test/integrations/modulesloader.test.ts @@ -1,5 +1,4 @@ import type { Client, Event, EventHint } from '@sentry/core'; - import { modulesLoaderIntegration } from '../../src/js/integrations/modulesloader'; import { NATIVE } from '../../src/js/wrapper'; diff --git a/packages/core/test/integrations/nativelinkederrors.test.ts b/packages/core/test/integrations/nativelinkederrors.test.ts index 9dd1a2da1b..60d0b1e6ff 100644 --- a/packages/core/test/integrations/nativelinkederrors.test.ts +++ b/packages/core/test/integrations/nativelinkederrors.test.ts @@ -1,6 +1,5 @@ import { defaultStackParser } from '@sentry/browser'; import type { Client, DebugImage, Event, EventHint, ExtendedError } from '@sentry/core'; - import { nativeLinkedErrorsIntegration } from '../../src/js/integrations/nativelinkederrors'; import type { NativeStackFrames } from '../../src/js/NativeRNSentry'; import { NATIVE } from '../../src/js/wrapper'; diff --git a/packages/core/test/integrations/primitiveTagIntegration.test.ts b/packages/core/test/integrations/primitiveTagIntegration.test.ts new file mode 100644 index 0000000000..e77a9aacd8 --- /dev/null +++ b/packages/core/test/integrations/primitiveTagIntegration.test.ts @@ -0,0 +1,95 @@ +import type { Client } from '@sentry/core'; +import { primitiveTagIntegration } from '../../src/js/integrations/primitiveTagIntegration'; +import { NATIVE } from '../../src/js/wrapper'; +import { setupTestClient } from '../mocks/client'; + +describe('primitiveTagIntegration', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupTestClient(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('integration setup', () => { + it('sets up beforeSendEvent handler', () => { + const integration = primitiveTagIntegration(); + const mockClient = { + on: jest.fn(), + } as any; + + integration.setup!(mockClient); + + expect(mockClient.on).toHaveBeenCalledWith('beforeSendEvent', expect.any(Function)); + }); + }); + + describe('beforeSendEvent processing', () => { + let beforeSendEventHandler: (event: any) => void; + + beforeEach(() => { + const integration = primitiveTagIntegration(); + const mockClient = { + on: jest.fn((eventName, handler) => { + if (eventName === 'beforeSendEvent') { + beforeSendEventHandler = handler; + } + }), + } as any; + + integration.setup!(mockClient); + }); + + it('handles events without tags', () => { + const event = { message: 'test' }; + + expect(() => beforeSendEventHandler(event)).not.toThrow(); + expect(event).toEqual({ message: 'test' }); + }); + + it('handles events with empty tags object', () => { + const event = { tags: {} }; + + expect(() => beforeSendEventHandler(event)).not.toThrow(); + expect(event.tags).toEqual({}); + }); + + it('handles events with null tags', () => { + const event = { tags: null }; + + expect(() => beforeSendEventHandler(event)).not.toThrow(); + expect(event.tags).toBeNull(); + }); + }); + + describe('integration with native processor', () => { + it('sets primitiveProcessor to PrimitiveToString function', () => { + const integration = primitiveTagIntegration(); + NATIVE.enableNative = true; + jest.spyOn(NATIVE, '_setPrimitiveProcessor'); + + integration.afterAllSetup!({ getOptions: () => ({}) } as Client); + + expect(NATIVE._setPrimitiveProcessor).toHaveBeenCalledWith(expect.any(Function)); + + // Verify the function passed is PrimitiveToString + const passedFunction = (NATIVE._setPrimitiveProcessor as jest.Mock).mock.calls[0][0]; + expect(passedFunction(true)).toBe('True'); + expect(passedFunction(false)).toBe('False'); + expect(passedFunction(null)).toBe(''); + expect(passedFunction(42)).toBe('42'); + }); + + it('does not set processor when native is disabled', () => { + const integration = primitiveTagIntegration(); + NATIVE.enableNative = false; + jest.spyOn(NATIVE, '_setPrimitiveProcessor'); + + integration.afterAllSetup!({ getOptions: () => ({}) } as Client); + + expect(NATIVE._setPrimitiveProcessor).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index 076008fc61..db5a8867f8 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -3,7 +3,6 @@ jest.mock('../../src/js/utils/environment'); import type { SeverityLevel } from '@sentry/core'; import { addGlobalUnhandledRejectionInstrumentationHandler, captureException, setCurrentClient } from '@sentry/core'; - import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers'; import { checkPromiseAndWarn, @@ -14,6 +13,8 @@ import { isHermesEnabled, isWeb } from '../../src/js/utils/environment'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +jest.mock('../../src/js/integrations/reactnativeerrorhandlersutils'); + let errorHandlerCallback: ((error: Error, isFatal?: boolean) => Promise) | null = null; jest.mock('../../src/js/utils/worldwide', () => { @@ -87,50 +88,50 @@ describe('ReactNativeErrorHandlers', () => { describe('onError', () => { test('Sets up the global error handler', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); - expect(RN_GLOBAL_OBJ.ErrorUtils.setGlobalHandler).toHaveBeenCalled(); + expect(RN_GLOBAL_OBJ.ErrorUtils!.setGlobalHandler).toHaveBeenCalled(); }); test('Sets handled:false on a fatal error', async () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(errorHandlerCallback).not.toBeNull(); - await errorHandlerCallback(new Error('Test Error'), true); + await errorHandlerCallback!(new Error('Test Error'), true); await client.flush(); const event = client.event; expect(event?.level).toBe('fatal' as SeverityLevel); - expect(event?.exception?.values?.[0].mechanism?.handled).toBe(false); - expect(event?.exception?.values?.[0].mechanism?.type).toBe('onerror'); + expect(event?.exception?.values?.[0]?.mechanism?.handled).toBe(false); + expect(event?.exception?.values?.[0]?.mechanism?.type).toBe('onerror'); }); test('Does not set handled:false on a non-fatal error', async () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(errorHandlerCallback).not.toBeNull(); - await errorHandlerCallback(new Error('Test Error'), false); + await errorHandlerCallback!(new Error('Test Error'), false); await client.flush(); const event = client.event; expect(event?.level).toBe('error' as SeverityLevel); - expect(event?.exception?.values?.[0].mechanism?.handled).toBe(true); - expect(event?.exception?.values?.[0].mechanism?.type).toBe('generic'); + expect(event?.exception?.values?.[0]?.mechanism?.handled).toBe(true); + expect(event?.exception?.values?.[0]?.mechanism?.type).toBe('generic'); }); test('Includes original exception in hint', async () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(errorHandlerCallback).not.toBeNull(); - await errorHandlerCallback(new Error('Test Error'), false); + await errorHandlerCallback!(new Error('Test Error'), false); await client.flush(); const hint = client.hint; @@ -145,7 +146,7 @@ describe('ReactNativeErrorHandlers', () => { (isHermesEnabled as jest.Mock).mockReturnValue(false); const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(polyfillPromise).toHaveBeenCalled(); expect(mockEnable).toHaveBeenCalledWith( @@ -179,7 +180,7 @@ describe('ReactNativeErrorHandlers', () => { (isHermesEnabled as jest.Mock).mockReturnValue(false); const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); const [options] = mockEnable.mock.calls[0]; const onUnhandledHandler = options.onUnhandled; @@ -208,7 +209,7 @@ describe('ReactNativeErrorHandlers', () => { test('uses native Hermes promise rejection tracking', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledTimes(1); expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledWith( @@ -224,7 +225,7 @@ describe('ReactNativeErrorHandlers', () => { test('captures unhandled rejection with Hermes tracker', async () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); const [options] = mockEnablePromiseRejectionTracker.mock.calls[0]; const onUnhandledHandler = options.onUnhandled; @@ -254,7 +255,7 @@ describe('ReactNativeErrorHandlers', () => { test('uses addGlobalUnhandledRejectionInstrumentationHandler for React Native Web', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledTimes(1); expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function)); @@ -266,7 +267,7 @@ describe('ReactNativeErrorHandlers', () => { test('captures unhandled rejection with the callback', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0]; @@ -287,7 +288,7 @@ describe('ReactNativeErrorHandlers', () => { test('handles non-error rejection with synthetic error', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0]; @@ -316,7 +317,7 @@ describe('ReactNativeErrorHandlers', () => { test('uses existing polyfill for JSC environments', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); + integration.setupOnce!(); expect(polyfillPromise).toHaveBeenCalledTimes(1); expect(requireRejectionTracking).toHaveBeenCalledTimes(1); @@ -324,7 +325,7 @@ describe('ReactNativeErrorHandlers', () => { test('respects patchGlobalPromise option', () => { const integration = reactNativeErrorHandlersIntegration({ patchGlobalPromise: false }); - integration.setupOnce(); + integration.setupOnce!(); expect(polyfillPromise).not.toHaveBeenCalled(); expect(requireRejectionTracking).not.toHaveBeenCalled(); diff --git a/packages/core/test/integrations/reactnativeinfo.test.ts b/packages/core/test/integrations/reactnativeinfo.test.ts index f7f9f3bfb9..2a6ab284be 100644 --- a/packages/core/test/integrations/reactnativeinfo.test.ts +++ b/packages/core/test/integrations/reactnativeinfo.test.ts @@ -1,5 +1,4 @@ import type { Client, Event, EventHint } from '@sentry/core'; - import type { ReactNativeError } from '../../src/js/integrations/debugsymbolicator'; import type { ReactNativeContext } from '../../src/js/integrations/reactnativeinfo'; import { reactNativeInfoIntegration } from '../../src/js/integrations/reactnativeinfo'; @@ -61,7 +60,7 @@ describe('React Native Info', () => { }, }, tags: { - hermes: 'true', + hermes: true, }, }); }); @@ -72,7 +71,7 @@ describe('React Native Info', () => { const actualEvent = await executeIntegrationFor({}, {}); expectMocksToBeCalledOnce(); - expect(actualEvent?.tags?.hermes).toEqual('true'); + expect(actualEvent?.tags?.hermes).toBeTrue(); expect(actualEvent?.contexts?.react_native_context).toEqual( expect.objectContaining({ js_engine: 'hermes', diff --git a/packages/core/test/integrations/release.test.ts b/packages/core/test/integrations/release.test.ts index be3a4f1b10..6a282f7d9b 100644 --- a/packages/core/test/integrations/release.test.ts +++ b/packages/core/test/integrations/release.test.ts @@ -1,5 +1,4 @@ import type { Client } from '@sentry/core'; - import { nativeReleaseIntegration } from '../../src/js/integrations/release'; jest.mock('../../src/js/wrapper', () => ({ diff --git a/packages/core/test/integrations/rewriteframes.test.ts b/packages/core/test/integrations/rewriteframes.test.ts index 45eb9f094c..9230583513 100644 --- a/packages/core/test/integrations/rewriteframes.test.ts +++ b/packages/core/test/integrations/rewriteframes.test.ts @@ -2,7 +2,6 @@ import type { Exception } from '@sentry/browser'; import { defaultStackParser, eventFromException } from '@sentry/browser'; import type { Client, Event, EventHint } from '@sentry/core'; import { Platform } from 'react-native'; - import { createReactNativeRewriteFrames } from '../../src/js/integrations/rewriteframes'; import { isExpo, isHermesEnabled } from '../../src/js/utils/environment'; import { mockFunction } from '../testutils'; diff --git a/packages/core/test/integrations/sdkinfo.test.ts b/packages/core/test/integrations/sdkinfo.test.ts index 912d7f7d93..b166491834 100644 --- a/packages/core/test/integrations/sdkinfo.test.ts +++ b/packages/core/test/integrations/sdkinfo.test.ts @@ -1,5 +1,4 @@ import type { Event, EventHint, Package } from '@sentry/core'; - import { SDK_NAME, SDK_VERSION } from '../../src/js'; import { sdkInfoIntegration } from '../../src/js/integrations/sdkinfo'; import { NATIVE } from '../../src/js/wrapper'; diff --git a/packages/core/test/integrations/spotlight.test.ts b/packages/core/test/integrations/spotlight.test.ts index dba566f260..d65f2c4385 100644 --- a/packages/core/test/integrations/spotlight.test.ts +++ b/packages/core/test/integrations/spotlight.test.ts @@ -1,10 +1,10 @@ import type { HttpRequestEventMap } from '@mswjs/interceptors'; import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'; import type { Client, Envelope } from '@sentry/core'; - import { spotlightIntegration } from '../../src/js/integrations/spotlight'; globalThis.XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; + const requestListener = jest.fn(); const interceptor = new XMLHttpRequestInterceptor(); interceptor.on('request', requestListener); diff --git a/packages/core/test/integrations/viewhierarchy.test.ts b/packages/core/test/integrations/viewhierarchy.test.ts index 69020ce8ac..574220d9c3 100644 --- a/packages/core/test/integrations/viewhierarchy.test.ts +++ b/packages/core/test/integrations/viewhierarchy.test.ts @@ -1,5 +1,4 @@ import type { Client, Event, EventHint } from '@sentry/core'; - import { viewHierarchyIntegration } from '../../src/js/integrations/viewhierarchy'; import { NATIVE } from '../../src/js/wrapper'; diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index c26c384e26..83d15681b5 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -16,6 +16,7 @@ const NATIVE: MockInterface = { _processLevel: jest.fn(), _serializeObject: jest.fn(), _isModuleLoaded: jest.fn(), + _setPrimitiveProcessor: jest.fn(), isNativeAvailable: jest.fn(), @@ -63,6 +64,7 @@ const NATIVE: MockInterface = { popTimeToDisplayFor: jest.fn(), setActiveSpanId: jest.fn(), encodeToBase64: jest.fn(), + primitiveProcessor: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); @@ -89,7 +91,7 @@ NATIVE.getCurrentReplayId.mockReturnValue(null); NATIVE.crashedLastRun.mockResolvedValue(false); NATIVE.popTimeToDisplayFor.mockResolvedValue(null); NATIVE.getNewScreenTimeToDisplay.mockResolvedValue(null); - +NATIVE.primitiveProcessor.mockReturnValue(''); export const getRNSentryModule = jest.fn(); export { NATIVE }; diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts index 1a18e94365..c2aa9c397e 100644 --- a/packages/core/test/mocks/client.ts +++ b/packages/core/test/mocks/client.ts @@ -9,7 +9,6 @@ import { resolvedSyncPromise, setCurrentClient, } from '@sentry/core'; - import type { ReactNativeClientOptions } from '../../src/js/options'; export function getDefaultTestClientOptions(options: Partial = {}): TestClientOptions { @@ -88,7 +87,7 @@ export class TestClient extends BaseClient { super.sendEvent(event, hint); return; } - TestClient.sendEventCalled && TestClient.sendEventCalled(event); + TestClient.sendEventCalled?.(event); } public sendSession(session: Session): void { diff --git a/packages/core/test/playground/modal.test.tsx b/packages/core/test/playground/modal.test.tsx index 29aab1b945..4d1fd9198b 100644 --- a/packages/core/test/playground/modal.test.tsx +++ b/packages/core/test/playground/modal.test.tsx @@ -1,6 +1,5 @@ import { render } from '@testing-library/react-native'; import * as React from 'react'; - import { SentryPlayground } from '../../src/js/playground/modal'; describe('PlaygroundComponent', () => { diff --git a/packages/core/test/profiling/convertHermesProfile.test.ts b/packages/core/test/profiling/convertHermesProfile.test.ts index 807d662fcd..569f6778fd 100644 --- a/packages/core/test/profiling/convertHermesProfile.test.ts +++ b/packages/core/test/profiling/convertHermesProfile.test.ts @@ -1,5 +1,4 @@ import type { ThreadCpuSample } from '@sentry/core'; - import { convertToSentryProfile, mapSamples } from '../../src/js/profiling/convertHermesProfile'; import type * as Hermes from '../../src/js/profiling/hermes'; import type { RawThreadCpuProfile } from '../../src/js/profiling/types'; diff --git a/packages/core/test/profiling/hermes.test.ts b/packages/core/test/profiling/hermes.test.ts index 21255b95ff..f2c8d64a9c 100644 --- a/packages/core/test/profiling/hermes.test.ts +++ b/packages/core/test/profiling/hermes.test.ts @@ -1,5 +1,4 @@ import type { ThreadCpuFrame } from '@sentry/core'; - import { parseHermesJSStackFrame } from '../../src/js/profiling/convertHermesProfile'; describe('hermes', () => { diff --git a/packages/core/test/profiling/integration.test.ts b/packages/core/test/profiling/integration.test.ts index 43a7d3cc75..9fb4e425cc 100644 --- a/packages/core/test/profiling/integration.test.ts +++ b/packages/core/test/profiling/integration.test.ts @@ -1,11 +1,11 @@ import * as mockWrapper from '../mockWrapper'; + jest.mock('../../src/js/wrapper', () => mockWrapper); jest.mock('../../src/js/utils/environment'); jest.mock('../../src/js/profiling/debugid'); import type { Envelope, Event, Integration, Profile, Span, ThreadCpuProfile, Transport } from '@sentry/core'; import { getClient, spanToJSON } from '@sentry/core'; - import * as Sentry from '../../src/js'; import { getDebugMetadata } from '../../src/js/profiling/debugid'; import type { HermesProfilingOptions } from '../../src/js/profiling/integration'; @@ -380,7 +380,7 @@ function initTestClient( const transportSendMock = jest.fn, Parameters>(); const options: Sentry.ReactNativeOptions = { dsn: MOCK_DSN, - enableTracing: true, + tracesSampleRate: 1.0, enableNativeFramesTracking: false, profilesSampleRate: 1, integrations: integrations => { @@ -466,5 +466,5 @@ function addIntegrationAndForceSetupOnce(integration: Integration): void { } client.addIntegration(integration); - integration.setupOnce && integration.setupOnce(); + integration.setupOnce?.(); } diff --git a/packages/core/test/replay/browserReplay.test.ts b/packages/core/test/replay/browserReplay.test.ts new file mode 100644 index 0000000000..7914c08f62 --- /dev/null +++ b/packages/core/test/replay/browserReplay.test.ts @@ -0,0 +1,23 @@ +import { describe, test } from '@jest/globals'; +import * as SentryReact from '@sentry/react'; +import { spyOn } from 'jest-mock'; +import { browserReplayIntegration } from '../../src/js/replay/browserReplay'; +import * as environment from '../../src/js/utils/environment'; + +describe('Browser Replay', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should not call replayIntegration if not web', () => { + spyOn(environment, 'notWeb').mockReturnValue(true); + spyOn(SentryReact, 'replayIntegration').mockImplementation(() => { + throw new Error('replayIntegration should not be called'); + }); + + const integration = browserReplayIntegration(); + + expect(integration).toBeDefined(); + expect(SentryReact.replayIntegration).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 477e8bc661..f348875dc7 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -1,5 +1,4 @@ import type { Breadcrumb } from '@sentry/core'; - import { enrichXhrBreadcrumbsForMobileReplay } from '../../src/js/replay/xhrUtils'; describe('xhrUtils', () => { diff --git a/packages/core/test/scopeSync.test.ts b/packages/core/test/scopeSync.test.ts index 85d14fe27c..29c6efae29 100644 --- a/packages/core/test/scopeSync.test.ts +++ b/packages/core/test/scopeSync.test.ts @@ -1,10 +1,11 @@ -jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); import type { Breadcrumb } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; - import { enableSyncToNative } from '../src/js/scopeSync'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; + +jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); + import { NATIVE } from './mockWrapper'; jest.mock('../src/js/wrapper'); @@ -134,7 +135,6 @@ describe('ScopeSync', () => { it('setUser', () => { expect(SentryCore.getIsolationScope().setUser).not.toBe(setUserScopeSpy); - const user = { id: '123' }; SentryCore.setUser(user); expect(NATIVE.setUser).toHaveBeenCalledExactlyOnceWith({ id: '123' }); @@ -142,6 +142,7 @@ describe('ScopeSync', () => { }); it('setTag', () => { + jest.spyOn(NATIVE, 'primitiveProcessor').mockImplementation((value: SentryCore.Primitive) => value as string); expect(SentryCore.getIsolationScope().setTag).not.toBe(setTagScopeSpy); SentryCore.setTag('key', 'value'); @@ -150,6 +151,7 @@ describe('ScopeSync', () => { }); it('setTags', () => { + jest.spyOn(NATIVE, 'primitiveProcessor').mockImplementation((value: SentryCore.Primitive) => value as string); expect(SentryCore.getIsolationScope().setTags).not.toBe(setTagsScopeSpy); SentryCore.setTags({ key: 'value', second: 'bar' }); diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index 85b4b7e030..6380216228 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -1,13 +1,13 @@ import type { BaseTransportOptions, Breadcrumb, BreadcrumbHint, ClientOptions, Integration, Scope } from '@sentry/core'; import { initAndBind, logger } from '@sentry/core'; import { makeFetchTransport } from '@sentry/react'; - import { getDevServer } from '../src/js/integrations/debugsymbolicatorutils'; 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'; @@ -140,6 +140,45 @@ describe('Tests the SDK functionality', () => { }); }); + describe('release', () => { + afterEach(() => { + (notWeb as jest.Mock).mockReset(); + RN_GLOBAL_OBJ.SENTRY_RELEASE = undefined; + }); + + it('uses release from global for web', () => { + RN_GLOBAL_OBJ.SENTRY_RELEASE = { + name: 'test', + version: '1.0.0', + }; + (notWeb as jest.Mock).mockImplementation(() => false); + init({}); + expect(usedOptions()?.release).toEqual('test@1.0.0'); + }); + + it('uses release from options for web', () => { + RN_GLOBAL_OBJ.SENTRY_RELEASE = { + name: 'test', + version: '1.0.0', + }; + (notWeb as jest.Mock).mockImplementation(() => false); + init({ + release: 'custom@2.0.0', + }); + expect(usedOptions()?.release).toEqual('custom@2.0.0'); + }); + + it('uses undefined for others', () => { + RN_GLOBAL_OBJ.SENTRY_RELEASE = { + name: 'test', + version: '1.0.0', + }; + (notWeb as jest.Mock).mockImplementation(() => true); + init({}); + expect(usedOptions()?.release).toBeUndefined(); + }); + }); + describe('transport options buffer size', () => { it('uses default transport options buffer size', () => { init({ @@ -620,6 +659,36 @@ describe('Tests the SDK functionality', () => { expectIntegration('HermesProfiling'); }); + it('adds browserSessionIntegration on web when enableAutoSessionTracking is set true', () => { + (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); + (notWeb as jest.Mock).mockImplementation(() => false); + init({ enableAutoSessionTracking: true }); + + expectIntegration('BrowserSession'); + }); + + it('no browserSessionIntegration on web when enableAutoSessionTracking is set false', () => { + (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); + (notWeb as jest.Mock).mockImplementation(() => false); + init({ enableAutoSessionTracking: false }); + + expectNotIntegration('BrowserSession'); + }); + + it('no browserSessionIntegration on web when enableAutoSessionTracking is not set', () => { + (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); + (notWeb as jest.Mock).mockImplementation(() => false); + init({}); + + expectNotIntegration('BrowserSession'); + }); + + it('no browserSessionIntegration on mobile', () => { + init({ enableAutoSessionTracking: true }); + + expectNotIntegration('BrowserSession'); + }); + it('no spotlight integration by default', () => { init({}); @@ -649,6 +718,15 @@ describe('Tests the SDK functionality', () => { expectNotIntegration('AppStart'); }); + it('when tracing enabled app start without native (on web, Expo Go) integration is not added', () => { + init({ + tracesSampleRate: 0.5, + enableNative: false, + }); + + expectNotIntegration('AppStart'); + }); + it('no native frames integration by default', () => { init({}); @@ -672,6 +750,15 @@ describe('Tests the SDK functionality', () => { expectNotIntegration('NativeFrames'); }); + it('when tracing enabled (on web, Expo Go) native frames integration is not added', () => { + init({ + tracesSampleRate: 0.5, + enableNative: false, + }); + + expectNotIntegration('NativeFrames'); + }); + it('when tracing not set stall tracking the integration is not added', () => { init({}); diff --git a/packages/core/test/sdk.withclient.test.ts b/packages/core/test/sdk.withclient.test.ts index 693d1817a9..c6aeedd81a 100644 --- a/packages/core/test/sdk.withclient.test.ts +++ b/packages/core/test/sdk.withclient.test.ts @@ -1,5 +1,4 @@ import { logger, setCurrentClient } from '@sentry/core'; - import { crashedLastRun, flush } from '../src/js/sdk'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; import { NATIVE } from './mockWrapper'; diff --git a/packages/core/test/tools/fixtures/mockBabelTransformer.js b/packages/core/test/tools/fixtures/mockBabelTransformer.js index 17628495a5..707b86771f 100644 --- a/packages/core/test/tools/fixtures/mockBabelTransformer.js +++ b/packages/core/test/tools/fixtures/mockBabelTransformer.js @@ -1,3 +1,7 @@ +var globals = require('@jest/globals'); + +var jest = globals.jest; + module.exports = { transform: jest.fn(), getCacheKey: jest.fn(), diff --git a/packages/core/test/tools/metroMiddleware.test.ts b/packages/core/test/tools/metroMiddleware.test.ts index b1743e27a8..426d3dea69 100644 --- a/packages/core/test/tools/metroMiddleware.test.ts +++ b/packages/core/test/tools/metroMiddleware.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import type { StackFrame } from '@sentry/core'; import * as fs from 'fs'; - import * as metroMiddleware from '../../src/js/tools/metroMiddleware'; const { withSentryMiddleware, createSentryMetroMiddleware, stackFramesContextMiddleware } = metroMiddleware; diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index 2329509754..e4ab6d2529 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -1,7 +1,6 @@ import type { getDefaultConfig } from 'expo/metro-config'; import type { MetroConfig } from 'metro'; import * as process from 'process'; - import { getSentryExpoConfig, withSentryBabelTransformer, @@ -160,7 +159,7 @@ describe('metroconfig', () => { describe.each([ ['new Metro', false, '0.70.0'], ['old Metro', true, '0.67.0'], - ])(`on %s`, (description, oldMetro, metroVersion) => { + ])('on %s', (description, oldMetro, metroVersion) => { beforeEach(() => { jest.resetModules(); // Mock metro/package.json diff --git a/packages/core/test/tools/sentryBabelTransformer.test.ts b/packages/core/test/tools/sentryBabelTransformer.test.ts index 3d6fb99d20..2a7868a5f9 100644 --- a/packages/core/test/tools/sentryBabelTransformer.test.ts +++ b/packages/core/test/tools/sentryBabelTransformer.test.ts @@ -1,5 +1,4 @@ import * as process from 'process'; - import { createSentryBabelTransformer, SENTRY_BABEL_TRANSFORMER_OPTIONS, diff --git a/packages/core/test/tools/sentryMetroSerializer.test.ts b/packages/core/test/tools/sentryMetroSerializer.test.ts index 1d85d2cdb3..411edecde3 100644 --- a/packages/core/test/tools/sentryMetroSerializer.test.ts +++ b/packages/core/test/tools/sentryMetroSerializer.test.ts @@ -3,7 +3,6 @@ import type { MixedOutput, Module } from 'metro'; import CountingSet from 'metro/src/lib/CountingSet'; import * as countLines from 'metro/src/lib/countLines'; import { minify } from 'uglify-js'; - import { createSentryMetroSerializer } from '../../src/js/tools/sentryMetroSerializer'; import { type MetroSerializer, type VirtualJSOutput, createDebugIdSnippet } from '../../src/js/tools/utils'; diff --git a/packages/core/test/tools/sentryReleaseInjector.test.ts b/packages/core/test/tools/sentryReleaseInjector.test.ts new file mode 100644 index 0000000000..b4c506a70b --- /dev/null +++ b/packages/core/test/tools/sentryReleaseInjector.test.ts @@ -0,0 +1,49 @@ +import type { MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import { unstableReleaseConstantsPlugin } from '../../src/js/tools/sentryReleaseInjector'; + +const mockedExpoConfigRequire = jest.fn(); + +jest.mock('@expo/config', () => ({ + getConfig: mockedExpoConfigRequire, +})); + +describe('Sentry Release Injector', () => { + beforeEach(() => { + mockedExpoConfigRequire.mockReturnValue({ + exp: { + name: 'TestApp', + version: '1.0.0', + }, + }); + }); + + test('unstableReleaseConstantsPlugin returns premodules if not web', () => { + const projectRoot = '/some/project/root'; + const graph = { + transformOptions: { platform: 'ios' }, + } as unknown as ReadOnlyGraph; + const premodules = [{ path: 'someModule.js' }] as Module[]; + + const plugin = unstableReleaseConstantsPlugin(projectRoot); + const result = plugin({ graph, premodules: [...premodules] }); + + expect(result).toEqual(premodules); + }); + + test('unstableReleaseConstantsPlugin returns premodules with Sentry release constants if web', () => { + const projectRoot = '/some/project/root'; + const graph = { + transformOptions: { platform: 'web' }, + } as unknown as ReadOnlyGraph; + const premodules = [{ path: 'someModule.js' }] as Module[]; + + const plugin = unstableReleaseConstantsPlugin(projectRoot); + const result = plugin({ graph, premodules }); + + expect(result.length).toBe(premodules.length + 1); + expect(result[0]?.path).toBe('__sentryReleaseConstants__'); + expect(result[0]?.getSource().toString()).toEqual( + 'var SENTRY_RELEASE;SENTRY_RELEASE={name: "TestApp", version: "1.0.0"};', + ); + }); +}); diff --git a/packages/core/test/tools/utils.test.ts b/packages/core/test/tools/utils.test.ts new file mode 100644 index 0000000000..b798307796 --- /dev/null +++ b/packages/core/test/tools/utils.test.ts @@ -0,0 +1,100 @@ +import type { Module } from 'metro'; +import * as countLines from 'metro/src/lib/countLines'; +import type { VirtualJSOutput } from '../../src/js/tools/utils'; +import { createSet, getExpoConfig, prependModule } from '../../src/js/tools/utils'; + +const mockedExpoConfigRequire = jest.fn(); + +jest.mock('@expo/config', () => ({ + getConfig: mockedExpoConfigRequire, +})); + +describe('Sentry Metro Tools Utils', () => { + describe('prependModule', () => { + test('module is added to position 0 if no prelude module', () => { + const module = mockModule('testModule.js', 'console.log("test");'); + const preModules = [mockModule('otherModule.js', 'console.log("other");')]; + + const result = prependModule(preModules, module); + + expect(result[0]).toEqual(module); + expect(result[1]).toEqual(preModules[0]); + }); + + test('module is added after prelude', () => { + const module = mockModule('testModule.js', 'console.log("test");'); + const preludeModule = mockModule('__prelude__', 'console.log("prelude");'); + const preModules = [preludeModule, mockModule('otherModule.js', 'console.log("other");')]; + + const result = prependModule(preModules, module); + + expect(result[0]).toEqual(preludeModule); + expect(result[1]).toEqual(module); + expect(result[2]).toEqual(preModules[1]); + }); + }); + + describe('getExpoConfig', () => { + test('returns empty object if @expo/config is not available', () => { + mockedExpoConfigRequire.mockImplementation(() => { + throw new Error('Module not found'); + }); + + const result = getExpoConfig('/some/project/root'); + expect(result).toStrictEqual({}); + }); + + test('returns config with name and version', () => { + mockedExpoConfigRequire.mockReturnValue({ + exp: { + name: 'TestApp', + version: '1.0.0', + }, + }); + + const result = getExpoConfig('/some/project/root'); + expect(result).toEqual({ name: 'TestApp', version: '1.0.0' }); + }); + + test('returns object with undefined(s) if name or version is not a string', () => { + mockedExpoConfigRequire.mockReturnValue({ + exp: { + name: 123, + version: null, + }, + }); + + const result = getExpoConfig('/some/project/root'); + expect(result).toEqual({ + name: undefined, + version: undefined, + }); + }); + + test('returns empty object if getConfig is not available', () => { + mockedExpoConfigRequire.mockReturnValue({}); + + const result = getExpoConfig('/some/project/root'); + expect(result).toEqual({}); + }); + }); +}); + +function mockModule(path: string, code: string): Module { + return { + dependencies: new Map(), + getSource: () => Buffer.from(code), + inverseDependencies: createSet(), + path, + output: [ + { + type: 'js/script/virtual', + data: { + code, + lineCount: countLines(code), + map: [], + }, + }, + ], + }; +} diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 4266f62d4b..32c5504386 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -3,7 +3,6 @@ */ import type { SeverityLevel } from '@sentry/core'; import * as core from '@sentry/core'; - import { TouchEventBoundary } from '../src/js/touchevents'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; diff --git a/packages/core/test/trace.test.ts b/packages/core/test/trace.test.ts index 0f84d36e20..94b81bcee5 100644 --- a/packages/core/test/trace.test.ts +++ b/packages/core/test/trace.test.ts @@ -1,5 +1,4 @@ import { setCurrentClient, spanToJSON, startSpan } from '@sentry/core'; - import { getDefaultTestClientOptions, TestClient } from './mocks/client'; describe('parentSpanIsAlwaysRootSpan', () => { diff --git a/packages/core/test/tracing/addTracingExtensions.test.ts b/packages/core/test/tracing/addTracingExtensions.test.ts index 4d4c5384c3..b413ffd429 100644 --- a/packages/core/test/tracing/addTracingExtensions.test.ts +++ b/packages/core/test/tracing/addTracingExtensions.test.ts @@ -1,5 +1,5 @@ +import type { Span } from '@sentry/core'; import { getCurrentScope, spanToJSON, startSpanManual } from '@sentry/core'; - import { reactNativeTracingIntegration } from '../../src/js'; import { type TestClient, setupTestClient } from '../mocks/client'; @@ -55,9 +55,12 @@ describe('Tracing extensions', () => { }); test('transaction start span passes correct values to the child', async () => { - const transaction = startSpanManual({ name: 'parent', op: 'custom', scope: getCurrentScope() }, span => span); - const span = startSpanManual({ name: 'child', scope: getCurrentScope() }, span => span); - span!.end(); + let childSpan: Span = undefined; + const transaction = startSpanManual({ name: 'parent', op: 'custom', scope: getCurrentScope() }, _span => { + childSpan = startSpanManual({ name: 'child', scope: getCurrentScope() }, __span => __span); + return _span; + }); + childSpan!.end(); transaction!.end(); await client.flush(); @@ -70,9 +73,9 @@ describe('Tracing extensions', () => { }), }), ); - expect(spanToJSON(span!)).toEqual( + expect(spanToJSON(childSpan!)).toEqual( expect.objectContaining({ - parent_span_id: transaction!.spanContext().spanId, + parent_span_id: spanToJSON(transaction!).span_id, }), ); }); diff --git a/packages/core/test/tracing/gesturetracing.test.ts b/packages/core/test/tracing/gesturetracing.test.ts index 10c5ccf39d..aa57bc199a 100644 --- a/packages/core/test/tracing/gesturetracing.test.ts +++ b/packages/core/test/tracing/gesturetracing.test.ts @@ -1,6 +1,5 @@ import type { Breadcrumb } from '@sentry/core'; import { getActiveSpan, spanToJSON, startSpan } from '@sentry/core'; - import { UI_ACTION } from '../../src/js/tracing'; import { DEFAULT_BREADCRUMB_CATEGORY as DEFAULT_GESTURE_BREADCRUMB_CATEGORY, diff --git a/packages/core/test/tracing/idleNavigationSpan.test.ts b/packages/core/test/tracing/idleNavigationSpan.test.ts index 74ca24bb7c..28fc38fcaf 100644 --- a/packages/core/test/tracing/idleNavigationSpan.test.ts +++ b/packages/core/test/tracing/idleNavigationSpan.test.ts @@ -1,7 +1,6 @@ import type { Span } from '@sentry/core'; import { getActiveSpan, getCurrentScope, spanToJSON, startSpanManual } from '@sentry/core'; import type { AppState, AppStateStatus } from 'react-native'; - import type { ScopeWithMaybeSpan } from '../../src/js/tracing/span'; import { SCOPE_SPAN_FIELD, startIdleNavigationSpan } from '../../src/js/tracing/span'; import { NATIVE } from '../../src/js/wrapper'; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 042114d240..9d62708d06 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -8,7 +8,6 @@ import { setCurrentClient, timestampInSeconds, } from '@sentry/core'; - import { APP_START_COLD as APP_START_COLD_MEASUREMENT, APP_START_WARM as APP_START_WARM_MEASUREMENT, @@ -162,7 +161,7 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Start', ); @@ -170,7 +169,7 @@ describe('App Start Integration', () => { expect(appStartRootSpan).toEqual( expect.objectContaining(>{ span_id: expect.any(String), - description: 'Cold App Start', + description: 'Cold Start', op: APP_START_COLD_OP, data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, @@ -200,7 +199,7 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Before React Root', ); @@ -208,7 +207,7 @@ describe('App Start Integration', () => { expect(appStartRootSpan).toEqual( expect.objectContaining(>{ span_id: expect.any(String), - description: 'Cold App Start', + description: 'Cold Start', op: APP_START_COLD_OP, data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, @@ -239,13 +238,13 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const nativeSpan = actualEvent!.spans!.find(({ description }) => description === 'test native app start span'); expect(appStartRootSpan).toEqual( expect.objectContaining(>{ span_id: expect.any(String), - description: 'Cold App Start', + description: 'Cold Start', op: APP_START_COLD_OP, data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, @@ -483,14 +482,14 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Start', ); expect(appStartRootSpan).toEqual( expect.objectContaining(>{ - description: 'Cold App Start', + description: 'Cold Start', span_id: expect.any(String), op: APP_START_COLD_OP, origin: SPAN_ORIGIN_AUTO_APP_START, @@ -523,14 +522,14 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Before React Root', ); expect(appStartRootSpan).toEqual( expect.objectContaining(>{ - description: 'Cold App Start', + description: 'Cold Start', span_id: expect.any(String), op: APP_START_COLD_OP, origin: SPAN_ORIGIN_AUTO_APP_START, @@ -563,14 +562,14 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Before React Root', ); expect(appStartRootSpan).toEqual( expect.objectContaining(>{ - description: 'Cold App Start', + description: 'Cold Start', span_id: expect.any(String), op: APP_START_COLD_OP, origin: SPAN_ORIGIN_AUTO_APP_START, @@ -604,12 +603,12 @@ describe('App Start Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); const nativeSpan = actualEvent!.spans!.find(({ description }) => description === 'test native app start span'); expect(appStartRootSpan).toEqual( expect.objectContaining(>{ - description: 'Cold App Start', + description: 'Cold Start', span_id: expect.any(String), op: APP_START_COLD_OP, origin: SPAN_ORIGIN_AUTO_APP_START, @@ -803,7 +802,7 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -830,7 +829,7 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Warm App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Warm Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -859,7 +858,7 @@ describe('Frame Data Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -888,7 +887,7 @@ describe('Frame Data Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Warm App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Warm Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -917,7 +916,7 @@ describe('Frame Data Integration', () => { const actualEvent = await processEvent(getMinimalTransactionEvent()); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -939,7 +938,7 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -963,7 +962,7 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold App Start'); + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); expect(appStartSpan).toBeDefined(); expect(appStartSpan!.data).toEqual( @@ -1043,6 +1042,7 @@ function getMinimalTransactionEvent({ description: 'Test', span_id: '123', trace_id: '456', + data: {}, }, ], }; @@ -1077,7 +1077,7 @@ function expectEventWithAttachedColdAppStart({ spans: expect.arrayContaining([ { op: APP_START_COLD_OP, - description: 'Cold App Start', + description: 'Cold Start', start_timestamp: appStartTimeMilliseconds / 1000, timestamp: expect.any(Number), trace_id: expect.any(String), @@ -1097,6 +1097,7 @@ function expectEventWithAttachedColdAppStart({ description: 'Test', span_id: '123', trace_id: '456', + data: {}, }, ]), }); @@ -1133,7 +1134,7 @@ function expectEventWithAttachedWarmAppStart({ spans: expect.arrayContaining([ { op: APP_START_WARM_OP, - description: 'Warm App Start', + description: 'Warm Start', start_timestamp: appStartTimeMilliseconds / 1000, timestamp: expect.any(Number), trace_id: expect.any(String), @@ -1153,6 +1154,7 @@ function expectEventWithAttachedWarmAppStart({ description: 'Test', span_id: '123', trace_id: '456', + data: {}, }, ]), }); @@ -1190,7 +1192,7 @@ function expectEventWithStandaloneColdAppStart( spans: expect.arrayContaining([ { op: APP_START_COLD_OP, - description: 'Cold App Start', + description: 'Cold Start', start_timestamp: appStartTimeMilliseconds / 1000, timestamp: expect.any(Number), trace_id: expect.any(String), @@ -1241,7 +1243,7 @@ function expectEventWithStandaloneWarmAppStart( spans: expect.arrayContaining([ { op: APP_START_WARM_OP, - description: 'Warm App Start', + description: 'Warm Start', start_timestamp: appStartTimeMilliseconds / 1000, timestamp: expect.any(Number), trace_id: expect.any(String), diff --git a/packages/core/test/tracing/integrations/nativeframes.test.ts b/packages/core/test/tracing/integrations/nativeframes.test.ts index 839bdd5d1b..0b426f945d 100644 --- a/packages/core/test/tracing/integrations/nativeframes.test.ts +++ b/packages/core/test/tracing/integrations/nativeframes.test.ts @@ -1,6 +1,5 @@ import type { Event, Measurements } from '@sentry/core'; import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpan } from '@sentry/core'; - import { nativeFramesIntegration } from '../../../src/js'; import { NATIVE } from '../../../src/js/wrapper'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; diff --git a/packages/core/test/tracing/integrations/stallTracking/stalltracking.background.test.ts b/packages/core/test/tracing/integrations/stallTracking/stalltracking.background.test.ts index 2358c77d1e..bf1eb231ee 100644 --- a/packages/core/test/tracing/integrations/stallTracking/stalltracking.background.test.ts +++ b/packages/core/test/tracing/integrations/stallTracking/stalltracking.background.test.ts @@ -1,5 +1,4 @@ import type { AppStateStatus } from 'react-native'; - import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; type StallTrackingWithTestProperties = ReturnType & { diff --git a/packages/core/test/tracing/integrations/stallTracking/stalltracking.test.ts b/packages/core/test/tracing/integrations/stallTracking/stalltracking.test.ts index 12fc59f1ab..1cadf3f7d5 100644 --- a/packages/core/test/tracing/integrations/stallTracking/stalltracking.test.ts +++ b/packages/core/test/tracing/integrations/stallTracking/stalltracking.test.ts @@ -9,7 +9,6 @@ import { startSpanManual, timestampInSeconds, } from '@sentry/core'; - import { stallTrackingIntegration } from '../../../../src/js/tracing/integrations/stalltracking'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; import { expectNonZeroStallMeasurements, expectStallMeasurements } from './stalltrackingutils'; diff --git a/packages/core/test/tracing/integrations/userInteraction.test.ts b/packages/core/test/tracing/integrations/userInteraction.test.ts index c152017a63..25f2053cb0 100644 --- a/packages/core/test/tracing/integrations/userInteraction.test.ts +++ b/packages/core/test/tracing/integrations/userInteraction.test.ts @@ -8,7 +8,6 @@ import { startSpanManual, } from '@sentry/core'; import type { AppState, AppStateStatus } from 'react-native'; - import { startUserInteractionSpan, userInteractionIntegration, @@ -256,15 +255,20 @@ describe('User Interaction Tracing', () => { }); test('do not start UI event transaction if active transaction on scope', () => { - const activeTransaction = startSpanManual( - { name: 'activeTransactionOnScope', scope: getCurrentScope() }, - (span: Span) => span, - ); - expect(activeTransaction).toBeDefined(); - expect(activeTransaction).toBe(getActiveSpan()); + const placeholderCallback: (span: Span, finish: () => void) => void = (span, finish) => { + // @ts-expect-error no direct access to _name + expect(span._name).toBe('activeTransactionOnScope'); - startUserInteractionSpan(mockedUserInteractionId); - expect(activeTransaction).toBe(getActiveSpan()); + expect(span).toBe(getActiveSpan()); + + startUserInteractionSpan(mockedUserInteractionId); + + expect(span).toBe(getActiveSpan()); + + finish(); + }; + + startSpanManual({ name: 'activeTransactionOnScope', scope: getCurrentScope() }, placeholderCallback); }); test('UI event transaction is canceled when routing transaction starts', () => { diff --git a/packages/core/test/tracing/mockedtimetodisplaynative.tsx b/packages/core/test/tracing/mockedtimetodisplaynative.tsx index 6fbc773b53..12c9b7e6ae 100644 --- a/packages/core/test/tracing/mockedtimetodisplaynative.tsx +++ b/packages/core/test/tracing/mockedtimetodisplaynative.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { View } from 'react-native'; - import type { RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; import { NATIVE } from '../mockWrapper'; diff --git a/packages/core/test/tracing/reactnativenavigation.test.ts b/packages/core/test/tracing/reactnativenavigation.test.ts index ebc264d91f..77d02db915 100644 --- a/packages/core/test/tracing/reactnativenavigation.test.ts +++ b/packages/core/test/tracing/reactnativenavigation.test.ts @@ -9,7 +9,6 @@ import { spanToJSON, } from '@sentry/core'; import type { EmitterSubscription } from 'react-native'; - import { reactNativeTracingIntegration } from '../../src/js'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NATIVE_NAVIGATION } from '../../src/js/tracing/origin'; import type { diff --git a/packages/core/test/tracing/reactnativetracing.test.ts b/packages/core/test/tracing/reactnativetracing.test.ts index 861b94ba1a..7aab318b6d 100644 --- a/packages/core/test/tracing/reactnativetracing.test.ts +++ b/packages/core/test/tracing/reactnativetracing.test.ts @@ -13,6 +13,11 @@ jest.mock('../../src/js/wrapper', () => { }; }); +import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; +import { isWeb } from '../../src/js/utils/environment'; +import type { TestClient } from '../mocks/client'; +import { setupTestClient } from '../mocks/client'; + jest.mock('../../src/js/tracing/utils', () => { const originalUtils = jest.requireActual('../../src/js/tracing/utils'); @@ -32,10 +37,6 @@ jest.mock('@sentry/core', () => { }); jest.mock('../../src/js/utils/environment'); -import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; -import { isWeb } from '../../src/js/utils/environment'; -import type { TestClient } from '../mocks/client'; -import { setupTestClient } from '../mocks/client'; describe('ReactNativeTracing', () => { let client: TestClient; diff --git a/packages/core/test/tracing/reactnavigation.stalltracking.test.ts b/packages/core/test/tracing/reactnavigation.stalltracking.test.ts index 0fbc3b8627..7f4f4ade6e 100644 --- a/packages/core/test/tracing/reactnavigation.stalltracking.test.ts +++ b/packages/core/test/tracing/reactnavigation.stalltracking.test.ts @@ -4,7 +4,6 @@ jest.mock('../../src/js/tracing/utils', () => ({ })); import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpanManual } from '@sentry/core'; - import { reactNativeTracingIntegration, reactNavigationIntegration } from '../../src/js'; import { stallTrackingIntegration } from '../../src/js/tracing/integrations/stalltracking'; import { isNearToNow } from '../../src/js/tracing/utils'; diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 54a203000f..db55874a91 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -10,7 +10,6 @@ import { setCurrentClient, spanToJSON, } from '@sentry/core'; - import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index 7830012e0d..89bcc1015d 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -1,11 +1,11 @@ import type { Scope, Span, SpanJSON, TransactionEvent, Transport } from '@sentry/core'; import { getActiveSpan, spanToJSON, timestampInSeconds } from '@sentry/core'; import * as TestRenderer from '@testing-library/react-native' -import * as React from "react"; - +import * as React from 'react'; import * as mockWrapper from '../mockWrapper'; import * as mockedSentryEventEmitter from '../utils/mockedSentryeventemitterfallback'; import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; + jest.mock('../../src/js/wrapper', () => mockWrapper); jest.mock('../../src/js/utils/environment'); jest.mock('../../src/js/utils/sentryeventemitterfallback', () => mockedSentryEventEmitter); @@ -24,6 +24,7 @@ import { nowInSeconds, secondInFutureTimestampMs } from '../testutils'; import { mockRecordedTimeToDisplay } from './mockedtimetodisplaynative'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; + const SCOPE_SPAN_FIELD = '_sentrySpan'; type ScopeWithMaybeSpan = Scope & { @@ -258,7 +259,7 @@ describe('React Navigation - TTID', () => { type: 'transaction', spans: expect.arrayContaining([ expect.objectContaining>({ - description: 'Cold App Start', + description: 'Cold Start', }), expect.objectContaining>({ data: { @@ -299,7 +300,7 @@ describe('React Navigation - TTID', () => { type: 'transaction', spans: expect.arrayContaining([ expect.objectContaining>({ - description: 'Cold App Start', + description: 'Cold Start', }), expect.objectContaining>({ data: { @@ -330,7 +331,7 @@ describe('React Navigation - TTID', () => { type: 'transaction', spans: expect.arrayContaining([ expect.objectContaining>({ - description: 'Cold App Start', + description: 'Cold Start', }), ]), measurements: expect.objectContaining['measurements']>({ @@ -681,7 +682,7 @@ function initSentry(sut: ReturnType): const transportSendMock = jest.fn, Parameters>(); const options: Sentry.ReactNativeOptions = { dsn: MOCK_DSN, - enableTracing: true, + tracesSampleRate: 1.0, enableStallTracking: false, integrations: [ sut, diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 43413abcbc..ab2b8e1744 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -1,30 +1,32 @@ +import type { Event, Measurements, Span, SpanJSON} from '@sentry/core'; import { getCurrentScope, getGlobalScope, getIsolationScope, logger , setCurrentClient, spanToJSON, startSpanManual } from '@sentry/core'; + jest.spyOn(logger, 'warn'); import * as mockWrapper from '../mockWrapper'; + jest.mock('../../src/js/wrapper', () => mockWrapper); import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; -jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); -import { isTurboModuleEnabled } from '../../src/js/utils/environment'; -jest.mock('../../src/js/utils/environment', () => ({ - isWeb: jest.fn().mockReturnValue(false), - isTurboModuleEnabled: jest.fn().mockReturnValue(false), -})); +jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); -import type { Event, Measurements, Span, SpanJSON} from '@sentry/core'; -import * as React from "react"; +import * as React from 'react'; import * as TestRenderer from 'react-test-renderer'; - import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timeToDisplayIntegration'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../src/js/tracing/semanticAttributes'; import { SPAN_THREAD_NAME , SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; +import { isTurboModuleEnabled } from '../../src/js/utils/environment'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { nowInSeconds, secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; +jest.mock('../../src/js/utils/environment', () => ({ + isWeb: jest.fn().mockReturnValue(false), + isTurboModuleEnabled: jest.fn().mockReturnValue(false), +})); + const { mockRecordedTimeToDisplay, getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative; jest.useFakeTimers({advanceTimers: true}); @@ -321,7 +323,7 @@ function getFullDisplaySpanJSON(spans: SpanJSON[]) { function expectFinishedInitialDisplaySpan(event: Event) { expect(getInitialDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.initial_display", + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.load.initial_display', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, @@ -337,7 +339,7 @@ function expectFinishedInitialDisplaySpan(event: Event) { function expectFinishedFullDisplaySpan(event: Event) { expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.load.full_display', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, @@ -354,7 +356,7 @@ function expectFinishedFullDisplaySpan(event: Event) { function expectDeadlineExceededFullDisplaySpan(event: Event) { expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.load.full_display', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, diff --git a/packages/core/test/tracing/timetodisplaynative.web.test.tsx b/packages/core/test/tracing/timetodisplaynative.web.test.tsx index 507c33a745..6956bc81a2 100644 --- a/packages/core/test/tracing/timetodisplaynative.web.test.tsx +++ b/packages/core/test/tracing/timetodisplaynative.web.test.tsx @@ -1,9 +1,15 @@ jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); + // Fixes TypeError: Cannot set property UIManager of # which has only a getter + delete RN.UIManager; RN.UIManager = {}; + return RN; }); +import { getRNSentryOnDrawReporter } from '../../src/js/tracing/timetodisplaynative'; +import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries'; + jest.mock('../../src/js/utils/rnlibraries', () => { const webLibrary = jest.requireActual('../../src/js/utils/rnlibraries.web'); return { @@ -11,9 +17,6 @@ jest.mock('../../src/js/utils/rnlibraries', () => { }; }); -import { getRNSentryOnDrawReporter } from '../../src/js/tracing/timetodisplaynative'; -import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries'; - describe('timetodisplaynative', () => { test('requireNativeComponent to be undefined', () => { expect(ReactNativeLibraries).toBeDefined(); diff --git a/packages/core/test/transports/encodePolyfill.test.ts b/packages/core/test/transports/encodePolyfill.test.ts new file mode 100644 index 0000000000..6bc319f97f --- /dev/null +++ b/packages/core/test/transports/encodePolyfill.test.ts @@ -0,0 +1,33 @@ +import { SDK_VERSION } from '@sentry/core'; +import { encodePolyfill, useEncodePolyfill } from '../../src/js/transports/encodePolyfill'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; + +const OriginalTextEncoder = RN_GLOBAL_OBJ.TextEncoder; + +const restoreTextEncoder = (): void => { + RN_GLOBAL_OBJ.TextEncoder = OriginalTextEncoder; +}; + +describe('useEncodePolyfill', () => { + afterEach(() => { + restoreTextEncoder(); + }); + + test('should use global encode factory if TextEncoder is available', () => { + RN_GLOBAL_OBJ.TextEncoder = MockedTextEncoder; + useEncodePolyfill(); + expect(RN_GLOBAL_OBJ.__SENTRY__?.[SDK_VERSION]?.encodePolyfill?.('')).toEqual(new Uint8Array([1, 2, 3])); + }); + + test('should use encode polyfill if TextEncoder is not available', () => { + RN_GLOBAL_OBJ.TextEncoder = undefined; + useEncodePolyfill(); + expect(RN_GLOBAL_OBJ.__SENTRY__?.[SDK_VERSION]?.encodePolyfill).toBe(encodePolyfill); + }); +}); + +class MockedTextEncoder { + public encode(_text: string): Uint8Array { + return new Uint8Array([1, 2, 3]); + } +} diff --git a/packages/core/test/transports/native.test.ts b/packages/core/test/transports/native.test.ts index 267f2b4d59..72a0c936ca 100644 --- a/packages/core/test/transports/native.test.ts +++ b/packages/core/test/transports/native.test.ts @@ -1,5 +1,4 @@ import type { Envelope } from '@sentry/core'; - import { NativeTransport } from '../../src/js/transports/native'; jest.mock('../../src/js/wrapper', () => ({ diff --git a/packages/core/test/utils/PrimitiveConverter.test.ts b/packages/core/test/utils/PrimitiveConverter.test.ts new file mode 100644 index 0000000000..0583eccd73 --- /dev/null +++ b/packages/core/test/utils/PrimitiveConverter.test.ts @@ -0,0 +1,49 @@ +import { PrimitiveToString } from '../../src/js/utils/primitiveConverter'; + +describe('Primitive to String', () => { + it('Doesnt change strings', () => { + expect(PrimitiveToString('1234')).toBe('1234'); + expect(PrimitiveToString('1234,1')).toBe('1234,1'); + expect(PrimitiveToString('abc')).toBe('abc'); + }); + + it('Converts boolean to uppercase', () => { + expect(PrimitiveToString(false)).toBe('False'); + expect(PrimitiveToString(true)).toBe('True'); + }); + + it('Keeps undefined', () => { + expect(PrimitiveToString(undefined)).toBeUndefined(); + }); + + it('Converts null to empty', () => { + expect(PrimitiveToString(null)).toBe(''); + }); + + test.each([ + [0, '0'], + [1, '1'], + [12345, '12345'], + [Number.MIN_VALUE, `${Number.MIN_VALUE}`], + [Number.MAX_VALUE, `${Number.MAX_VALUE}`], + [Number.MIN_SAFE_INTEGER, `${Number.MIN_SAFE_INTEGER}`], + [Number.MAX_SAFE_INTEGER, `${Number.MAX_SAFE_INTEGER}`], + ])('Converts %p to "%s"', (input, expected) => { + expect(PrimitiveToString(input)).toBe(expected); + }); + + test.each([ + [BigInt('0'), '0'], + [BigInt('1'), '1'], + [BigInt('-1'), '-1'], + [BigInt('123456789012345678901234567890'), '123456789012345678901234567890'], + [BigInt('-98765432109876543210987654321'), '-98765432109876543210987654321'], + ])('converts bigint %p to "%s"', (input, expected) => { + expect(PrimitiveToString(input)).toBe(expected); + }); + + it('Symbol to String', () => { + const symbol = Symbol('a symbol'); + expect(PrimitiveToString(symbol)).toBe('Symbol(a symbol)'); + }); +}); diff --git a/packages/core/test/utils/ignorerequirecyclelogs.test.ts b/packages/core/test/utils/ignorerequirecyclelogs.test.ts index 11f62b9d2c..52b87e691d 100644 --- a/packages/core/test/utils/ignorerequirecyclelogs.test.ts +++ b/packages/core/test/utils/ignorerequirecyclelogs.test.ts @@ -1,5 +1,4 @@ import { LogBox } from 'react-native'; - import { ignoreRequireCycleLogs } from '../../src/js/utils/ignorerequirecyclelogs'; jest.mock('react-native', () => ({ diff --git a/packages/core/test/utils/mockedSentryeventemitterfallback.ts b/packages/core/test/utils/mockedSentryeventemitterfallback.ts index 74ebd4f53a..9ad6dcdf5d 100644 --- a/packages/core/test/utils/mockedSentryeventemitterfallback.ts +++ b/packages/core/test/utils/mockedSentryeventemitterfallback.ts @@ -1,6 +1,5 @@ import { timestampInSeconds } from '@sentry/core'; import * as EventEmitter from 'events'; - import type { NewFrameEvent } from '../../src/js/utils/sentryeventemitter'; import type { SentryEventEmitterFallback } from '../../src/js/utils/sentryeventemitterfallback'; import type { MockInterface } from '../testutils'; diff --git a/packages/core/test/utils/safe.test.ts b/packages/core/test/utils/safe.test.ts index 89e063bd35..0e781ef313 100644 --- a/packages/core/test/utils/safe.test.ts +++ b/packages/core/test/utils/safe.test.ts @@ -38,14 +38,31 @@ describe('safe', () => { test('calls given function with correct args', () => { const mockFn = jest.fn(); const actualSafeFunction = safeTracesSampler(mockFn); - actualSafeFunction?.({ name: 'foo', transactionContext: { name: 'foo' } }); + const expectedInheritOrSampleWith = function (fallbackSampleRate: number): number { + return fallbackSampleRate; + }; + actualSafeFunction?.({ + name: 'foo', + transactionContext: { name: 'foo' }, + inheritOrSampleWith: expectedInheritOrSampleWith, + }); expect(mockFn).toBeCalledTimes(1); - expect(mockFn).toBeCalledWith({ name: 'foo', transactionContext: { name: 'foo' } }); + expect(mockFn).toBeCalledWith({ + name: 'foo', + transactionContext: { name: 'foo' }, + inheritOrSampleWith: expectedInheritOrSampleWith, + }); }); test('calls given function amd return its result', () => { const mockFn = jest.fn(() => 0.5); const actualSafeFunction = safeTracesSampler(mockFn); - const actualResult = actualSafeFunction?.({ name: 'foo', transactionContext: { name: 'foo' } }); + const actualResult = actualSafeFunction?.({ + name: 'foo', + transactionContext: { name: 'foo' }, + inheritOrSampleWith: function (fallbackSampleRate: number): number { + return fallbackSampleRate; + }, + }); expect(mockFn).toBeCalledTimes(1); expect(actualResult).toBe(0.5); }); @@ -58,7 +75,13 @@ describe('safe', () => { throw 'Test error'; }); const actualSafeFunction = safeTracesSampler(mockFn); - const actualResult = actualSafeFunction?.({ name: 'foo', transactionContext: { name: 'foo' } }); + const actualResult = actualSafeFunction?.({ + name: 'foo', + transactionContext: { name: 'foo' }, + inheritOrSampleWith: function (fallbackSampleRate: number): number { + return fallbackSampleRate; + }, + }); expect(mockFn).toBeCalledTimes(1); expect(actualResult).toEqual(0); }); diff --git a/packages/core/test/utils/sentryeventemitterfallback.test.ts b/packages/core/test/utils/sentryeventemitterfallback.test.ts index 28353fa490..f55d57a891 100644 --- a/packages/core/test/utils/sentryeventemitterfallback.test.ts +++ b/packages/core/test/utils/sentryeventemitterfallback.test.ts @@ -1,5 +1,4 @@ import { logger } from '@sentry/core'; - import { NewFrameEventName } from '../../src/js/utils/sentryeventemitter'; import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback'; diff --git a/packages/core/test/wrap.mocked.test.tsx b/packages/core/test/wrap.mocked.test.tsx new file mode 100644 index 0000000000..ee9fda34bb --- /dev/null +++ b/packages/core/test/wrap.mocked.test.tsx @@ -0,0 +1,144 @@ +// We can't test wrap with mock and non mocked components, otherwise it will break the RN testing library. +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import type { ReactNativeWrapperOptions } from 'src/js/options'; +import * as environment from '../src/js/utils/environment'; + +jest.doMock('../src/js/touchevents', () => { + return { + TouchEventBoundary: ({ children }: { children: React.ReactNode }) => ( + // eslint-disable-next-line react/no-unknown-property +
{children}
+ ), + }; +}); + +jest.doMock('../src/js/tracing', () => { + return { + ReactNativeProfiler: jest.fn(({ children }: { children: React.ReactNode }) => ( + // eslint-disable-next-line react/no-unknown-property +
{children}
+ )), + }; +}); + +jest.doMock('@sentry/react', () => { + return { + Profiler: jest.fn(({ children }: { children: React.ReactNode }) => ( + // eslint-disable-next-line react/no-unknown-property +
{children}
+ )), + }; +}); + +jest.doMock('../src/js/feedback/FeedbackWidgetProvider', () => { + return { + FeedbackWidgetProvider: ({ children }: { children: React.ReactNode }) => ( + // eslint-disable-next-line react/no-unknown-property +
{children}
+ ), + }; +}); + +import { wrap } from '../src/js/sdk'; +import { ReactNativeProfiler } from '../src/js/tracing'; + +describe('Sentry.wrap', () => { + const DummyComponent: React.FC<{ value?: string }> = ({ value }) =>
{value}
; + + it('should not enforce any keys on the wrapped component', () => { + const Mock: React.FC<{ test: 23 }> = () => <>; + const ActualWrapped = wrap(Mock); + + expect(typeof ActualWrapped.defaultProps).toBe(typeof Mock.defaultProps); + }); + + it('wraps components with Sentry wrappers', () => { + const Wrapped = wrap(DummyComponent); + const renderResult = render(); + + expect(renderResult.toJSON()).toMatchInlineSnapshot(` +
+
+
+
+ wrapped +
+
+
+
+`); + }); + + it('wraps components with JS React Profiler on web', () => { + jest.spyOn(environment, 'isWeb').mockReturnValueOnce(true); + + const Wrapped = wrap(DummyComponent); + const renderResult = render(); + + expect(renderResult.toJSON()).toMatchInlineSnapshot(` +
+
+
+
+ wrapped +
+
+
+
+`); + }); + + describe('ReactNativeProfiler', () => { + it('uses given options when set', () => { + const options: ReactNativeWrapperOptions = { + profilerProps: { disabled: false, includeRender: true, includeUpdates: true }, + }; + const Wrapped = wrap(DummyComponent, options); + render(); + + expect(ReactNativeProfiler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Root', + disabled: false, + includeRender: true, + includeUpdates: true, + }), + expect.anything(), + ); + + expect(ReactNativeProfiler).not.toHaveBeenCalledWith( + expect.objectContaining({ + updateProps: expect.anything(), + }), + ); + }); + + it('ignore updateProps when set', () => { + const { wrap } = jest.requireActual('../src/js/sdk'); + + const Wrapped = wrap(DummyComponent, { updateProps: ['prop'] }); + render(); + + expect(ReactNativeProfiler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Root', + updateProps: {}, + }), + expect.anything(), + ); + }); + }); +}); diff --git a/packages/core/test/wrap.test.tsx b/packages/core/test/wrap.test.tsx index f949b36d38..8282683772 100644 --- a/packages/core/test/wrap.test.tsx +++ b/packages/core/test/wrap.test.tsx @@ -1,36 +1,29 @@ +// We can't test wrap with mock and non mocked components, otherwise it will break the RN testing library. import { logger, setCurrentClient } from '@sentry/core'; import { render } from '@testing-library/react-native'; import * as React from 'react'; import { Text } from 'react-native'; - import * as AppRegistry from '../src/js/integrations/appRegistry'; import { wrap } from '../src/js/sdk'; import { getDefaultTestClientOptions, TestClient } from './mocks/client'; -describe('Sentry.wrap', () => { - it('should not enforce any keys on the wrapped component', () => { - const Mock: React.FC<{ test: 23 }> = () => <>; - const ActualWrapped = wrap(Mock); - - expect(typeof ActualWrapped.defaultProps).toBe(typeof Mock.defaultProps); - }); - - it('should wrap the component and init with a warning when getAppRegistryIntegration returns undefined', () => { - logger.warn = jest.fn(); - const getAppRegistryIntegration = jest.spyOn(AppRegistry, 'getAppRegistryIntegration').mockReturnValueOnce(undefined); - const Mock: React.FC = () => Test; - const client = new TestClient( - getDefaultTestClientOptions(), - ); - setCurrentClient(client); +describe('ReactNativeProfiler', () => { + it('should wrap the component and init with a warning when getAppRegistryIntegration returns undefined', () => { + logger.warn = jest.fn(); + const getAppRegistryIntegration = jest.spyOn(AppRegistry, 'getAppRegistryIntegration').mockReturnValueOnce(undefined); + const Mock: React.FC = () => Test; + const client = new TestClient( + getDefaultTestClientOptions(), + ); + setCurrentClient(client); - client.init(); - const ActualWrapped = wrap(Mock); + client.init(); + const ActualWrapped = wrap(Mock); - const { getByText } = render(); + const { getByText } = render(); - expect(getAppRegistryIntegration).toHaveBeenCalled(); - expect(logger.warn).toHaveBeenCalledWith('AppRegistryIntegration.onRunApplication not found or invalid.'); - expect(getByText('Test')).toBeTruthy(); + expect(getAppRegistryIntegration).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith('AppRegistryIntegration.onRunApplication not found or invalid.'); + expect(getByText('Test')).toBeTruthy(); + }); }); -}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index f0cc9be167..c5d071228a 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -1,7 +1,6 @@ import type { Event, EventEnvelope, EventItem, SeverityLevel } from '@sentry/core'; import { createEnvelope, logger } from '@sentry/core'; import * as RN from 'react-native'; - import type { Spec } from '../src/js/NativeRNSentry'; import type { ReactNativeOptions } from '../src/js/options'; import { base64StringFromByteArray, utf8ToBytes } from '../src/js/vendor'; @@ -282,6 +281,37 @@ describe('Tests Native Wrapper', () => { expect(RNSentry.setContext).not.toBeCalled(); expect(RNSentry.setExtra).not.toBeCalled(); }); + + test('sets ignoreErrorsStr and ignoreErrorsRegex correctly when ignoreErrors contains strings and regex', async () => { + const regex1 = /foo/; + const regex2 = new RegExp('bar'); + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + ignoreErrors: ['string1', regex1, 'string2', regex2], + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + expect(RNSentry.initNativeSdk).toBeCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.ignoreErrorsStr).toEqual(['string1', 'string2']); + expect(initParameter.ignoreErrorsRegex).toEqual([regex1.source, regex2.source]); + }); + + test('does not set ignoreErrorsStr or ignoreErrorsRegex if ignoreErrors is not provided', async () => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + expect(RNSentry.initNativeSdk).toBeCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.ignoreErrorsStr).toBeUndefined(); + expect(initParameter.ignoreErrorsRegex).toBeUndefined(); + }); }); describe('sendEnvelope', () => { @@ -305,7 +335,7 @@ describe('Tests Native Wrapper', () => { base64StringFromByteArray( utf8ToBytes( '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":87}\n' + + '{"type":"event","content_type":"application/vnd.sentry.items.log+json","length":87}\n' + '{"event_id":"event0","message":"test","sdk":{"name":"test-sdk-name","version":"2.1.3"}}\n', ), ), @@ -337,7 +367,7 @@ describe('Tests Native Wrapper', () => { base64StringFromByteArray( utf8ToBytes( '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":93}\n' + + '{"type":"event","content_type":"application/vnd.sentry.items.log+json","length":93}\n' + '{"event_id":"event0","sdk":{"name":"test-sdk-name","version":"2.1.3"},"instance":{"value":0}}\n', ), ), @@ -380,7 +410,7 @@ describe('Tests Native Wrapper', () => { base64StringFromByteArray( utf8ToBytes( '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":50}\n' + + '{"type":"event","content_type":"application/vnd.sentry.items.log+json","length":50}\n' + '{"event_id":"event0","message":{"message":"test"}}\n', ), ), @@ -419,7 +449,7 @@ describe('Tests Native Wrapper', () => { base64StringFromByteArray( utf8ToBytes( '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":124}\n' + + '{"type":"event","content_type":"application/vnd.sentry.items.log+json","length":124}\n' + '{"event_id":"event0","exception":{"values":[{"mechanism":{"handled":true,"type":""}}]},"breadcrumbs":[{"message":"crumb!"}]}\n', ), ), @@ -448,7 +478,7 @@ describe('Tests Native Wrapper', () => { base64StringFromByteArray( utf8ToBytes( '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":58}\n' + + '{"type":"event","content_type":"application/vnd.sentry.items.log+json","length":58}\n' + '{"event_id":"event0","breadcrumbs":[{"message":"crumb!"}]}\n', ), ), @@ -487,7 +517,7 @@ describe('Tests Native Wrapper', () => { base64StringFromByteArray( utf8ToBytes( '{"event_id":"event0","sent_at":"123"}\n' + - '{"type":"event","content_type":"application/json","length":132}\n' + + '{"type":"event","content_type":"application/vnd.sentry.items.log+json","length":132}\n' + '{"event_id":"event0","exception":{"values":[{"mechanism":{"handled":false,"type":"onerror"}}]},"breadcrumbs":[{"message":"crumb!"}]}\n', ), ), @@ -808,4 +838,101 @@ describe('Tests Native Wrapper', () => { expect(result).toBeNull(); }); }); + + describe('primitiveProcessor and _setPrimitiveProcessor', () => { + describe('primitiveProcessor', () => { + it('default primitiveProcessor returns value as string', () => { + expect(NATIVE.primitiveProcessor('test')).toBe('test'); + expect(NATIVE.primitiveProcessor(123)).toBe(123); + expect(NATIVE.primitiveProcessor(true)).toBe(true); + expect(NATIVE.primitiveProcessor(null)).toBe(null); + expect(NATIVE.primitiveProcessor(undefined)).toBe(undefined); + }); + + it('handles all primitive types correctly', () => { + const testCases = [ + { input: 'string', expected: 'string' }, + { input: 42, expected: 42 }, + { input: true, expected: true }, + { input: false, expected: false }, + { input: null, expected: null }, + { input: undefined, expected: undefined }, + { input: BigInt(123), expected: BigInt(123) }, + ]; + + testCases.forEach(({ input, expected }) => { + expect(NATIVE.primitiveProcessor(input)).toBe(expected); + }); + }); + }); + + describe('_setPrimitiveProcessor', () => { + it('sets primitiveProcessor to the provided function', () => { + const mockProcessor = jest.fn(value => `processed_${value}`); + + NATIVE._setPrimitiveProcessor(mockProcessor); + + expect(NATIVE.primitiveProcessor).toBe(mockProcessor); + }); + + it('allows custom processing of primitive values', () => { + const customProcessor = (value: any) => { + if (typeof value === 'boolean') { + return value ? 'YES' : 'NO'; + } + if (value === null) { + return 'NULL'; + } + return String(value); + }; + + NATIVE._setPrimitiveProcessor(customProcessor); + + expect(NATIVE.primitiveProcessor(true)).toBe('YES'); + expect(NATIVE.primitiveProcessor(false)).toBe('NO'); + expect(NATIVE.primitiveProcessor(null)).toBe('NULL'); + expect(NATIVE.primitiveProcessor(42)).toBe('42'); + expect(NATIVE.primitiveProcessor('test')).toBe('test'); + }); + + it('can be chained with PrimitiveToString for consistent formatting', () => { + const { PrimitiveToString } = require('../src/js/utils/primitiveConverter'); + + NATIVE._setPrimitiveProcessor(PrimitiveToString); + + expect(NATIVE.primitiveProcessor(true)).toBe('True'); + expect(NATIVE.primitiveProcessor(false)).toBe('False'); + expect(NATIVE.primitiveProcessor(null)).toBe(''); + expect(NATIVE.primitiveProcessor(42)).toBe('42'); + expect(NATIVE.primitiveProcessor('test')).toBe('test'); + expect(NATIVE.primitiveProcessor(undefined)).toBeUndefined(); + }); + + it('can be reset to default behavior', () => { + const customProcessor = jest.fn(); + NATIVE._setPrimitiveProcessor(customProcessor); + expect(NATIVE.primitiveProcessor).toBe(customProcessor); + + const defaultProcessor = (value: any) => value; + NATIVE._setPrimitiveProcessor(defaultProcessor); + expect(NATIVE.primitiveProcessor).toBe(defaultProcessor); + }); + + it('works with primitiveTagIntegration', () => { + const { primitiveTagIntegration } = require('../src/js/integrations/primitiveTagIntegration'); + + const client = { + on: jest.fn(), + }; + + const integration = primitiveTagIntegration(); + integration.setup(client); + integration.afterAllSetup(); + + expect(NATIVE.primitiveProcessor(true)).toBe('True'); + expect(NATIVE.primitiveProcessor(false)).toBe('False'); + expect(NATIVE.primitiveProcessor(null)).toBe(''); + }); + }); + }); }); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index 0b6ab9e09f..ac956235ba 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -27,9 +27,7 @@ "target": "es6", "module": "es6", "skipLibCheck": true, - "allowSyntheticDefaultImports": true, "strictBindCallApply": true, - "strictNullChecks": false, "importHelpers": false } } diff --git a/packages/core/tsconfig.build.tools.json b/packages/core/tsconfig.build.tools.json index ac4f55f2cd..ac86a9a64d 100644 --- a/packages/core/tsconfig.build.tools.json +++ b/packages/core/tsconfig.build.tools.json @@ -14,7 +14,6 @@ "target": "es6", "module": "CommonJS", "skipLibCheck": true, - "allowSyntheticDefaultImports": true, "importHelpers": false } } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 76c7318349..33d561135e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -14,7 +14,6 @@ "plugin/**/*.ts" ], "exclude": ["dist"], - "allowSyntheticDefaultImports": true, "compilerOptions": { "rootDir": ".", "jsx": "react", diff --git a/performance-tests/TestAppPlain/package.json b/performance-tests/TestAppPlain/package.json index c4427bfd8b..97dbbe72a3 100644 --- a/performance-tests/TestAppPlain/package.json +++ b/performance-tests/TestAppPlain/package.json @@ -1,6 +1,6 @@ { "name": "TestAppPlain", - "version": "6.19.0", + "version": "7.0.0-rc.1", "private": true, "scripts": { "android": "react-native run-android", diff --git a/performance-tests/TestAppSentry/package.json b/performance-tests/TestAppSentry/package.json index 16a6c28a02..af87e1d916 100644 --- a/performance-tests/TestAppSentry/package.json +++ b/performance-tests/TestAppSentry/package.json @@ -1,6 +1,6 @@ { "name": "TestAppSentry", - "version": "6.19.0", + "version": "7.0.0-rc.1", "private": true, "scripts": { "android": "react-native run-android", @@ -8,7 +8,7 @@ "start": "react-native start" }, "dependencies": { - "@sentry/react-native": "6.19.0", + "@sentry/react-native": "7.0.0-rc.1", "react": "18.1.0", "react-native": "0.70.15" }, diff --git a/samples/expo/app.json b/samples/expo/app.json index c2ff8455f4..8503ea9ce4 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -5,7 +5,7 @@ "jsEngine": "hermes", "newArchEnabled": true, "scheme": "sentry-expo-sample", - "version": "6.19.0", + "version": "7.0.0-rc.1", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -20,7 +20,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", - "buildNumber": "54" + "buildNumber": "55" }, "android": { "adaptiveIcon": { @@ -28,7 +28,7 @@ "backgroundColor": "#ffffff" }, "package": "io.sentry.expo.sample", - "versionCode": 54 + "versionCode": 55 }, "web": { "bundler": "metro", diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index fe6a630adb..1a624c8f15 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -1,7 +1,6 @@ -import { Button, StyleSheet } from 'react-native'; -import Constants from 'expo-constants'; +import { Button, ScrollView, StyleSheet } from 'react-native'; import * as Sentry from '@sentry/react-native'; -import { reloadAppAsync } from 'expo'; +import { reloadAppAsync, isRunningInExpoGo } from 'expo'; import * as DevClient from 'expo-dev-client'; import { Text, View } from '@/components/Themed'; @@ -11,113 +10,190 @@ import * as WebBrowser from 'expo-web-browser'; import { useUpdates } from 'expo-updates'; import { isWeb } from '../../utils/isWeb'; -const isRunningInExpoGo = Constants.appOwnership === 'expo'; - export default function TabOneScreen() { const { currentlyRunning } = useUpdates(); return ( - - - Welcome to Sentry Expo Sample App! - Update ID: {currentlyRunning.updateId} - Channel: {currentlyRunning.channel} - Runtime Version: {currentlyRunning.runtimeVersion} -