From 942161665edbac984baf81bc4e1ec289ebb0e58c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 15 Jul 2024 23:22:41 +0200 Subject: [PATCH] feat(replay): Add Mobile Replay (#3830) * feat(replay): Add Mobile Replay Alpha (#3714) * feat(sample): add running indicator (animation overlay) (#3903) * feat(replay): Add breadcrumbs mapping from RN to RRWeb format (#3846) * feat(replay): Add network breadcrumbs (#3912) * fix(replay): Add tests for touch events (#3924) * feat(replay): Filter Sentry event breadcrumbs (#3925) * fix(changelog): Add latest native SDKs details * release: 5.25.0-alpha.2 * misc(samples): Add console anything examples for replay testing (#3928) * feat: Add Sentry Babel Transformer (#3916) * fix(replay): Add app lifecycle breadcrumbs conversion tests (#3932) * chore(deps): bump sentry-android to 7.12.0-alpha.3 * chore(deps): bump sentry-android to 7.12.0-alpha.4 * fix(replay): Mask SVGs from `react-native-svg` when `maskAllVectors=true` (#3930) * fix(replay): Add missing properties to android nav breadcrumbs (#3942) * release: 5.26.0-alpha.3 * misc(replay): Add Mobile Replay Public Beta changelog (#3943) --------- Co-authored-by: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Co-authored-by: Ivan Dlugos Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot Co-authored-by: Roman Zavarnitsyn Co-authored-by: Bruno Garcia --- .gitignore | 3 + CHANGELOG.md | 131 +++++- .../RNSentryReplayBreadcrumbConverterTest.kt | 170 ++++++++ .../project.pbxproj | 11 +- ...RNSentryCocoaTesterTests-Bridging-Header.h | 5 + ...SentryReplayBreadcrumbConverterTests.swift | 183 ++++++++ .../io/sentry/react/RNSentryModuleImpl.java | 65 ++- .../RNSentryReplayBreadcrumbConverter.java | 187 ++++++++ .../java/io/sentry/react/RNSentryModule.java | 10 + .../java/io/sentry/react/RNSentryModule.java | 10 + ios/RNSentry.mm | 34 +- ios/RNSentryReplay.h | 8 + ios/RNSentryReplay.m | 72 ++++ ios/RNSentryReplayBreadcrumbConverter.h | 16 + ios/RNSentryReplayBreadcrumbConverter.m | 168 ++++++++ package.json | 3 +- samples/expo/app.json | 6 +- samples/expo/app/_layout.tsx | 5 + samples/expo/babel.config.js | 3 - samples/expo/metro.config.js | 1 + samples/expo/package.json | 2 +- samples/expo/utils/setScopeProperties.ts | 7 + samples/react-native/android/app/build.gradle | 4 +- samples/react-native/babel.config.js | 4 +- .../ios/sentryreactnativesample/Info.plist | 4 +- .../sentryreactnativesampleTests/Info.plist | 4 +- samples/react-native/metro.config.js | 4 +- samples/react-native/package.json | 4 +- samples/react-native/src/App.tsx | 91 +++- .../src/Screens/PlaygroundScreen.tsx | 102 +++++ .../src/Screens/TrackerScreen.tsx | 12 +- .../src/components/SvgGraphic.tsx | 288 +++++++++++++ .../react-native/src/setScopeProperties.ts | 7 + samples/react-native/src/utils.ts | 7 + samples/react-native/yarn.lock | 408 +++++++++++++++++- src/js/NativeRNSentry.ts | 2 + src/js/client.ts | 20 +- src/js/integrations/default.ts | 15 + src/js/integrations/exports.ts | 2 + src/js/integrations/index.ts | 1 + src/js/options.ts | 32 +- src/js/replay/mobilereplay.ts | 145 +++++++ src/js/replay/networkUtils.ts | 64 +++ src/js/replay/xhrUtils.ts | 52 +++ src/js/tools/enableLogger.ts | 10 + src/js/tools/metroconfig.ts | 70 ++- src/js/tools/sentryBabelTransformer.ts | 43 ++ src/js/tools/sentryBabelTransformerUtils.ts | 65 +++ .../vendor/metro/metroBabelTransformer.ts | 64 +++ src/js/touchevents.tsx | 97 +++-- src/js/utils/clientutils.ts | 10 + src/js/utils/environment.ts | 10 + src/js/utils/worldwide.ts | 6 + src/js/version.ts | 2 +- src/js/wrapper.ts | 40 +- test/client.test.ts | 8 +- test/react-native/rn.patch.metro.config.js | 13 +- test/replay/networkUtils.test.ts | 59 +++ test/replay/xhrUtils.test.ts | 89 ++++ test/tools/fixtures/mockBabelTransformer.js | 4 + test/tools/metroconfig.test.ts | 113 ++++- test/tools/sentryBabelTransformer.test.ts | 87 ++++ yarn.lock | 5 + 63 files changed, 3061 insertions(+), 106 deletions(-) create mode 100644 RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt create mode 100644 RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift create mode 100644 android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java create mode 100644 ios/RNSentryReplay.h create mode 100644 ios/RNSentryReplay.m create mode 100644 ios/RNSentryReplayBreadcrumbConverter.h create mode 100644 ios/RNSentryReplayBreadcrumbConverter.m create mode 100644 samples/react-native/src/Screens/PlaygroundScreen.tsx create mode 100644 samples/react-native/src/components/SvgGraphic.tsx create mode 100644 samples/react-native/src/utils.ts create mode 100644 src/js/replay/mobilereplay.ts create mode 100644 src/js/replay/networkUtils.ts create mode 100644 src/js/replay/xhrUtils.ts create mode 100644 src/js/tools/enableLogger.ts create mode 100644 src/js/tools/sentryBabelTransformer.ts create mode 100644 src/js/tools/sentryBabelTransformerUtils.ts create mode 100644 src/js/tools/vendor/metro/metroBabelTransformer.ts create mode 100644 src/js/utils/clientutils.ts create mode 100644 test/replay/networkUtils.test.ts create mode 100644 test/replay/xhrUtils.test.ts create mode 100644 test/tools/fixtures/mockBabelTransformer.js create mode 100644 test/tools/sentryBabelTransformer.test.ts diff --git a/.gitignore b/.gitignore index 818f97beb..87a34b65e 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ yalc.lock # E2E tests test/react-native/versions + +# Created by Sentry Metro Plugin +.sentry/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac9e50dc..f0b91758c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ ## Unreleased +### Features + +- Session Replay Public Beta ([#3830](https://github.com/getsentry/sentry-react-native/pull/3830)) + + To enable Replay use the `replaysSessionSampleRate` or `replaysOnErrorSampleRate` options. + + ```js + import * as Sentry from '@sentry/react-native'; + + Sentry.init({ + _experiments: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }, + }); + ``` + + To add React Component Names use `annotateReactComponents` in `metro.config.js`. + + ```js + // For Expo + const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + const config = getSentryExpoConfig(__dirname, { annotateReactComponents: true }); + + // For RN + const { getDefaultConfig } = require('@react-native/metro-config'); + const { withSentryConfig } = require('@sentry/react-native/metro'); + module.exports = withSentryConfig(getDefaultConfig(__dirname), { annotateReactComponents: true }); + ``` + + To change default redaction behavior add the `mobileReplayIntegration`. + + ```js + import * as Sentry from '@sentry/react-native'; + + Sentry.init({ + _experiments: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }, + integrations: [ + Sentry.mobileReplayIntegration({ + maskAllImages: true, + maskAllVectors: true, + maskAllText: true, + }), + ], + }); + ``` + + To learn more visit [Sentry's Mobile Session Replay](https://docs.sentry.io/product/explore/session-replay/mobile/) documentation page. + ### Dependencies - Bump Cocoa SDK from v8.30.0 to v8.31.1 ([#3954](https://github.com/getsentry/sentry-react-native/pull/3954)) @@ -34,6 +86,30 @@ - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7110) - [diff](https://github.com/getsentry/sentry-java/compare/7.10.0...7.11.0) +## 5.25.0-alpha.2 + +### Features + +- Improve touch event component info if annotated with [`@sentry/babel-plugin-component-annotate`](https://www.npmjs.com/package/@sentry/babel-plugin-component-annotate) ([#3899](https://github.com/getsentry/sentry-react-native/pull/3899)) +- Add replay breadcrumbs for touch & navigation events ([#3846](https://github.com/getsentry/sentry-react-native/pull/3846)) +- Add network data to Session Replays ([#3912](https://github.com/getsentry/sentry-react-native/pull/3912)) +- Filter Sentry Event Breadcrumbs from Mobile Replays ([#3925](https://github.com/getsentry/sentry-react-native/pull/3925)) + +### Fixes + +- `sentry-expo-upload-sourcemaps` no longer requires Sentry url when uploading sourcemaps to `sentry.io` ([#3915](https://github.com/getsentry/sentry-react-native/pull/3915)) + +### Dependencies + +- Bump Cocoa SDK from v8.25.0-alpha.0 to v8.30.0 ([#3914](https://github.com/getsentry/sentry-react-native/pull/3914)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8300) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.25.0-alpha.0...8.30.0) +- Bump Android SDK from v7.9.0-alpha.1 to v7.11.0-alpha.2 ([#3830](https://github.com/getsentry/sentry-react-native/pull/3830)) + - [changelog](https://github.com/getsentry/sentry-java/blob/7.11.0-alpha.2/CHANGELOG.md#7110-alpha2) + - [diff](https://github.com/getsentry/sentry-java/compare/7.9.0-alpha.1...7.11.0-alpha.2) + +Access to Mobile Replay is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/) + ## 5.24.1 ### Fixes @@ -133,6 +209,14 @@ This release does *not* build on iOS. Please use `5.23.1` or newer. - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8270) - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.26.0...8.27.0) +## 5.23.0-alpha.1 + +### Fixes + +- Pass `replaysSessionSampleRate` option to Android ([#3714](https://github.com/getsentry/sentry-react-native/pull/3714)) + +Access to Mobile Replay is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/) + ## 5.22.3 ### Fixes @@ -166,6 +250,47 @@ This release does *not* build on iOS. Please use `5.23.1` or newer. - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8250) - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.24.0...8.25.0) +## 5.23.0-alpha.0 + +### Features + +- Mobile Session Replay Alpha ([#3714](https://github.com/getsentry/sentry-react-native/pull/3714)) + + To enable Replay for React Native on mobile and web add the following options. + + ```js + Sentry.init({ + _experiments: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }, + }); + ``` + + To change the default Mobile Replay options add the `mobileReplayIntegration`. + + ```js + Sentry.init({ + _experiments: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + }, + integration: [ + Sentry.mobileReplayIntegration({ + maskAllText: true, + maskAllImages: true, + }), + ], + }); + ``` + + Access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/) + +### Dependencies + +- Bump Cocoa SDK to [8.25.0-alpha.0](https://github.com/getsentry/sentry-cocoa/releases/tag/8.25.0-alpha.0) +- Bump Android SDK to [7.9.0-alpha.1](https://github.com/getsentry/sentry-java/releases/tag/7.9.0-alpha.1) + ## 5.22.0 ### Features @@ -444,7 +569,7 @@ see [the Expo guide](https://docs.sentry.io/platforms/react-native/manual-setup/ const { getSentryExpoConfig } = require("@sentry/react-native/metro"); // const config = getDefaultConfig(__dirname); - const config = getSentryExpoConfig(config, {}); + const config = getSentryExpoConfig(__dirname); ``` - New `npx sentry-expo-upload-sourcemaps` for simple EAS Update (`npx expo export`) source maps upload ([#3491](https://github.com/getsentry/sentry-react-native/pull/3491), [#3510](https://github.com/getsentry/sentry-react-native/pull/3510), [#3515](https://github.com/getsentry/sentry-react-native/pull/3515), [#3507](https://github.com/getsentry/sentry-react-native/pull/3507)) @@ -676,7 +801,7 @@ This release is compatible with `expo@50.0.0-preview.6` and newer. }); ``` - Read more at https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md#7690 + Read more at - Report current screen in `contexts.app.view_names` ([#3339](https://github.com/getsentry/sentry-react-native/pull/3339)) @@ -2715,7 +2840,7 @@ We are looking into ways making this more stable and plan to re-enable it again ## v0.23.2 -- Fixed #228 again ¯\\_(ツ)_/¯ +- Fixed #228 again ¯\\*(ツ)*/¯ ## v0.23.1 diff --git a/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt new file mode 100644 index 000000000..257aea123 --- /dev/null +++ b/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -0,0 +1,170 @@ +package io.sentry.rnsentryandroidtester + +import io.sentry.Breadcrumb +import io.sentry.SentryLevel +import io.sentry.react.RNSentryReplayBreadcrumbConverter +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEventType +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class RNSentryReplayBreadcrumbConverterTest { + + @Test + fun convertNavigationBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.level = SentryLevel.INFO + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "navigation" + testBreadcrumb.setData("from", "HomeScreen") + testBreadcrumb.setData("to", "ProfileScreen") + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals(SentryLevel.INFO, actual.level) + assertEquals("navigation", actual.category) + assertEquals("HomeScreen", actual.data?.get("from")) + assertEquals("ProfileScreen", actual.data?.get("to")) + } + + @Test + fun convertNavigationBreadcrumbWithOnlyTo() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.level = SentryLevel.INFO + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "navigation" + testBreadcrumb.setData("to", "ProfileScreen") + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals(SentryLevel.INFO, actual.level) + assertEquals("navigation", actual.category) + assertEquals(null, actual.data?.get("from")) + assertEquals("ProfileScreen", actual.data?.get("to")) + } + + @Test + fun convertForegroundBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "app.lifecycle" + testBreadcrumb.setData("state", "foreground"); + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals("app.foreground", actual.category) + } + + @Test + fun convertBackgroundBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "app.lifecycle" + testBreadcrumb.setData("state", "background"); + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals("app.background", actual.category) + } + + @Test + fun doesNotConvertSentryEventBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb(); + testBreadcrumb.category = "sentry.event" + val actual = converter.convert(testBreadcrumb) + assertEquals(null, actual) + } + + @Test + fun doesNotConvertSentryTransactionBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb(); + testBreadcrumb.category = "sentry.transaction" + val actual = converter.convert(testBreadcrumb) + assertEquals(null, actual) + } + + @Test + fun convertTouchBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.level = SentryLevel.INFO + testBreadcrumb.type = "user" + testBreadcrumb.category = "touch" + testBreadcrumb.message = "this won't be used for replay" + testBreadcrumb.setData( + "path", + arrayListOf(mapOf( + "element" to "element4", + "file" to "file4"))) + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals(SentryLevel.INFO, actual.level) + assertEquals("ui.tap", actual.category) + assertEquals(1, actual.data?.keys?.size) + assertEquals( + arrayListOf(mapOf( + "element" to "element4", + "file" to "file4")), + actual.data?.get("path")) + } + + @Test + fun doesNotConvertNullPath() { + val actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(null) + assertEquals(null, actual) + } + + @Test + fun doesNotConvertPathContainingNull() { + val actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(arrayListOf(arrayOfNulls(1))) + assertEquals(null, actual) + } + + @Test + fun doesNotConvertPathWithValuesMissingNameAndLevel() { + val actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(arrayListOf(mapOf( + "element" to "element4", + "file" to "file4"))) + assertEquals(null, actual) + } + + @Test + fun doesConvertValidPathExample1() { + val actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(listOf( + mapOf("label" to "label0"), + mapOf("name" to "name1"), + mapOf("name" to "item2", "label" to "label2"), + mapOf("name" to "item3", "label" to "label3", "element" to "element3"), + mapOf("name" to "item4", "label" to "label4", "file" to "file4"), + mapOf("name" to "item5", "label" to "label5", "element" to "element5", "file" to "file5"))) + assertEquals("label3(element3) > label2 > name1 > label0", actual) + } + + @Test + fun doesConvertValidPathExample2() { + val actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(listOf( + mapOf("name" to "item2", "label" to "label2"), + mapOf("name" to "item3", "label" to "label3", "element" to "element3"), + mapOf("name" to "item4", "label" to "label4", "file" to "file4"), + mapOf("name" to "item5", "label" to "label5", "element" to "element5", "file" to "file5"), + mapOf("label" to "label6"), + mapOf("name" to "name7"))) + assertEquals("label5(element5, file5) > label4(file4) > label3(element3) > label2", actual) + } + + private fun assertRRWebBreadcrumbDefaults(actual: RRWebBreadcrumbEvent) { + assertEquals("default", actual.breadcrumbType) + assertEquals(actual.breadcrumbTimestamp * 1000, actual.timestamp.toDouble(), 0.05) + assert(actual.breadcrumbTimestamp > 0) + } +} diff --git a/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 2418b704b..839420845 100644 --- a/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -7,7 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 3360843D2C340C76008CC412 /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; + 330F308C2C0F3840002A0D4E /* RNSentryBreadcrumbTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 330F308B2C0F3840002A0D4E /* RNSentryBreadcrumbTests.m */; }; + 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; @@ -18,7 +19,9 @@ /* Begin PBXFileReference section */ 1482D5685A340AB93348A43D /* Pods-RNSentryCocoaTesterTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.release.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.release.xcconfig"; sourceTree = ""; }; 330F308D2C0F385A002A0D4E /* RNSentryBreadcrumb.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryBreadcrumb.h; path = ../ios/RNSentryBreadcrumb.h; sourceTree = ""; }; - 3360843B2C340C75008CC412 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; + 336084372C32E382008CC412 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; + 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = ""; }; + 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; 3360898D29524164007C7730 /* RNSentryCocoaTesterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RNSentryCocoaTesterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 338739072A7D7D2800950DDD /* RNSentryTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryTests.h; sourceTree = ""; }; @@ -77,6 +80,7 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */, 33F58ACF2977037D008F60EA /* RNSentryTests.mm */, 338739072A7D7D2800950DDD /* RNSentryTests.h */, 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */, @@ -93,6 +97,7 @@ 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, 330F308D2C0F385A002A0D4E /* RNSentryBreadcrumb.h */, 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */, 33AFE0132B8F31AF00AAB120 /* RNSentryDependencyContainer.h */, @@ -136,6 +141,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1420; TargetAttributes = { 3360898C29524164007C7730 = { @@ -210,6 +216,7 @@ buildActionMask = 2147483647; files = ( 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, + 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, 3360843D2C340C76008CC412 /* RNSentryBreadcrumbTests.swift in Sources */, diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index 8da96b338..e177d453f 100644 --- a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -1 +1,6 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +#import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryBreadcrumb.h" diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift new file mode 100644 index 000000000..85f4376e6 --- /dev/null +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift @@ -0,0 +1,183 @@ +import XCTest +import Sentry + +final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { + + func testConvertNavigationBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.level = .info + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "navigation" + testBreadcrumb.data = [ + "from": "HomeScreen", + "to": "ProfileScreen", + ] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?] + let payloadData = payload["data"] as! [String: Any?] + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual("info", payload["level"] as! String) + XCTAssertEqual("navigation", payload["category"] as! String) + XCTAssertEqual("HomeScreen", payloadData["from"] as! String) + XCTAssertEqual("ProfileScreen", payloadData["to"] as! String) + } + + func testConvertNavigationBreadcrumbWithOnlyTo() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.level = .info + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "navigation" + testBreadcrumb.data = [ + "to": "ProfileScreen", + ] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?] + let payloadData = payload["data"] as! [String: Any?] + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual("info", payload["level"] as! String) + XCTAssertEqual("navigation", payload["category"] as! String) + XCTAssertNil(payloadData["from"] ?? nil) + XCTAssertEqual("ProfileScreen", payloadData["to"] as! String) + } + + func testConvertForegroundBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "app.lifecycle" + testBreadcrumb.data = ["state": "foreground"] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?]; + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual(payload["category"] as! String, "app.foreground") + } + + func testConvertBackgroundBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.type = "navigation" + testBreadcrumb.category = "app.lifecycle" + testBreadcrumb.data = ["state": "background"] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?]; + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual(payload["category"] as! String, "app.background") + } + + func testNotConvertSentryEventBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "sentry.event" + let actual = converter.convert(from: testBreadcrumb) + XCTAssertNil(actual) + } + + func testNotConvertSentryTransactionBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "sentry.transaction" + let actual = converter.convert(from: testBreadcrumb) + XCTAssertNil(actual) + } + + func testConvertTouchBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.level = .info + testBreadcrumb.type = "user" + testBreadcrumb.category = "touch" + testBreadcrumb.message = "this won't be used for replay" + testBreadcrumb.data = [ + "path": [ + ["element": "element4", "file": "file4"] + ] + ] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?] + let payloadData = payload["data"] as! [String: Any?] + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual("info", payload["level"] as! String) + XCTAssertEqual("ui.tap", payload["category"] as! String) + XCTAssertEqual(1, payloadData.keys.count) + XCTAssertEqual([[ + "element": "element4", + "file": "file4" + ]], payloadData["path"] as! [[String: String]]) + } + + func testTouchMessageReturnsNilOnEmptyArray() throws { + let actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(from: []) + XCTAssertEqual(actual, nil); + } + + func testTouchMessageReturnsNilOnNilArray() throws { + let actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(from: nil as [Any]?) + XCTAssertEqual(actual, nil); + } + + func testTouchMessageReturnsNilOnMissingNameAndLevel() throws { + let testPath: [Any?] = [["element": "element4", "file": "file4"]] + let actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(from: testPath as [Any]) + XCTAssertEqual(actual, nil); + } + + func testTouchMessageReturnsMessageOnValidPathExample1() throws { + let testPath: [Any?] = [ + ["label": "label0"], + ["name": "name1"], + ["name": "item2", "label": "label2"], + ["name": "item3", "label": "label3", "element": "element3"], + ["name": "item4", "label": "label4", "file": "file4"], + ["name": "item5", "label": "label5", "element": "element5", "file": "file5"], + ] + let actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(from: testPath as [Any]) + XCTAssertEqual(actual, "label3(element3) > label2 > name1 > label0"); + } + + func testTouchMessageReturnsMessageOnValidPathExample2() throws { + let testPath: [Any?] = [ + ["name": "item2", "label": "label2"], + ["name": "item3", "label": "label3", "element": "element3"], + ["name": "item4", "label": "label4", "file": "file4"], + ["name": "item5", "label": "label5", "element": "element5", "file": "file5"], + ["label": "label6"], + ["name": "name7"], + ] + let actual = RNSentryReplayBreadcrumbConverter.getTouchPathMessage(from: testPath as [Any]) + XCTAssertEqual(actual, "label5(element5, file5) > label4(file4) > label3(element3) > label2"); + } + + private func assertRRWebBreadcrumbDefaults(actual: [String: Any?]) { + let data = actual["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?] + XCTAssertEqual("default", payload["type"] as! String) + XCTAssertEqual((payload["timestamp"] as! Double) * 1000.0, Double(actual["timestamp"] as! Int), accuracy: 1.0) + XCTAssertTrue(payload["timestamp"] as! Double > 0.0) + } + +} diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index fadf66d05..7954bc149 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -41,9 +41,11 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.CountDownLatch; import io.sentry.Breadcrumb; @@ -61,6 +63,7 @@ import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.SentryReplayOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.AndroidProfiler; @@ -79,6 +82,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryException; +import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; @@ -186,7 +190,7 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); options.setNativeSdkName(NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); + options.setSdkVersion(sdkVersion); if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { options.setDebug(true); @@ -252,7 +256,10 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { if (rnOptions.hasKey("enableNdk")) { options.setEnableNdk(rnOptions.getBoolean("enableNdk")); } - + if (rnOptions.hasKey("_experiments")) { + options.getExperimental().setSessionReplay(getReplayOptions(rnOptions)); + options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); + } options.setBeforeSend((event, hint) -> { // React native internally throws a JavascriptException // Since we catch it before that, we don't want to send this one @@ -293,6 +300,42 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { promise.resolve(true); } + private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { + @NotNull final SentryReplayOptions androidReplayOptions = new SentryReplayOptions(); + + @Nullable final ReadableMap rnExperimentsOptions = rnOptions.getMap("_experiments"); + if (rnExperimentsOptions == null) { + return androidReplayOptions; + } + + if (!(rnExperimentsOptions.hasKey("replaysSessionSampleRate") || rnExperimentsOptions.hasKey("replaysOnErrorSampleRate"))) { + return androidReplayOptions; + } + + androidReplayOptions.setSessionSampleRate(rnExperimentsOptions.hasKey("replaysSessionSampleRate") + ? rnExperimentsOptions.getDouble("replaysSessionSampleRate") : null); + androidReplayOptions.setErrorSampleRate(rnExperimentsOptions.hasKey("replaysOnErrorSampleRate") + ? rnExperimentsOptions.getDouble("replaysOnErrorSampleRate") : null); + + if (!rnOptions.hasKey("mobileReplayOptions")) { + return androidReplayOptions; + } + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + if (rnMobileReplayOptions == null) { + return androidReplayOptions; + } + + androidReplayOptions.setRedactAllText(!rnMobileReplayOptions.hasKey("maskAllText") || rnMobileReplayOptions.getBoolean("maskAllText")); + androidReplayOptions.setRedactAllImages(!rnMobileReplayOptions.hasKey("maskAllImages") || rnMobileReplayOptions.getBoolean("maskAllImages")); + + final boolean redactVectors = !rnMobileReplayOptions.hasKey("maskAllVectors") || rnMobileReplayOptions.getBoolean("maskAllVectors"); + if (redactVectors) { + androidReplayOptions.addClassToRedact("com.horcrux.svg.SvgView"); // react-native-svg + } + + return androidReplayOptions; + } + public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } @@ -394,6 +437,24 @@ public void fetchNativeFrames(Promise promise) { } } + public void captureReplay(boolean isHardCrash, Promise promise) { + Sentry.getCurrentHub().getOptions().getReplayController().sendReplay(isHardCrash, null, null); + promise.resolve(getCurrentReplayId()); + } + + public @Nullable String getCurrentReplayId() { + final @Nullable IScope scope = InternalSentrySdk.getCurrentScope(); + if (scope == null) { + return null; + } + + final @NotNull SentryId id = scope.getReplayId(); + if (id == SentryId.EMPTY_ID) { + return null; + } + return id.toString(); + } + public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { byte[] bytes = Base64.decode(rawBytes, Base64.DEFAULT); diff --git a/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java new file mode 100644 index 000000000..4d6457a15 --- /dev/null +++ b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -0,0 +1,187 @@ +package io.sentry.react; + +import io.sentry.Breadcrumb; +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; +import io.sentry.rrweb.RRWebEvent; +import io.sentry.rrweb.RRWebBreadcrumbEvent; +import io.sentry.rrweb.RRWebSpanEvent; + +import java.util.HashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +import java.util.List; +import java.util.Map; + +public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadcrumbConverter { + public RNSentryReplayBreadcrumbConverter() { + } + + @Override + public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) { + if (breadcrumb.getCategory() == null) { + return null; + } + + // Do not add Sentry Event breadcrumbs to replay + if (breadcrumb.getCategory().equals("sentry.event") || + breadcrumb.getCategory().equals("sentry.transaction")) { + return null; + } + if (breadcrumb.getCategory().equals("http")) { + // Drop native http breadcrumbs to avoid duplicates + return null; + } + + if (breadcrumb.getCategory().equals("touch")) { + return convertTouchBreadcrumb(breadcrumb); + } + if (breadcrumb.getCategory().equals("navigation")) { + return convertNavigationBreadcrumb(breadcrumb); + } + if (breadcrumb.getCategory().equals("xhr")) { + return convertNetworkBreadcrumb(breadcrumb); + } + + RRWebEvent nativeBreadcrumb = super.convert(breadcrumb); + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb instanceof RRWebBreadcrumbEvent) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb; + if (rrWebBreadcrumb.getCategory() != null && rrWebBreadcrumb.getCategory().equals("navigation")) { + return null; + } + } + + return nativeBreadcrumb; + } + + @TestOnly + public @NotNull RRWebEvent convertNavigationBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); + rrWebBreadcrumb.setCategory(breadcrumb.getCategory()); + setRRWebEventDefaultsFrom(rrWebBreadcrumb, breadcrumb); + return rrWebBreadcrumb; + } + + @TestOnly + public @NotNull RRWebEvent convertTouchBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); + + rrWebBreadcrumb.setCategory("ui.tap"); + + rrWebBreadcrumb.setMessage(RNSentryReplayBreadcrumbConverter + .getTouchPathMessage(breadcrumb.getData("path"))); + + setRRWebEventDefaultsFrom(rrWebBreadcrumb, breadcrumb); + return rrWebBreadcrumb; + } + + @TestOnly + public static @Nullable String getTouchPathMessage(final @Nullable Object maybePath) { + if (!(maybePath instanceof List)) { + return null; + } + + final @NotNull List path = (List) maybePath; + if (path.size() == 0) { + return null; + } + + final @NotNull StringBuilder message = new StringBuilder(); + for (int i = Math.min(3, path.size() - 1); i >= 0; i--) { + final @Nullable Object maybeItem = path.get(i); + if (!(maybeItem instanceof Map)) { + return null; + } + + final @NotNull Map item = (Map) maybeItem; + final @Nullable Object maybeName = item.get("name"); + final @Nullable Object maybeLabel = item.get("label"); + boolean hasName = maybeName instanceof String; + boolean hasLabel = maybeLabel instanceof String; + if (!hasName && !hasLabel) { + return null; // This again should never be allowed in JS, but to be safe we check it here + } + if (hasLabel) { + message.append(maybeLabel); + } else { // hasName is true + message.append(maybeName); + } + + final @Nullable Object maybeElement = item.get("element"); + final @Nullable Object maybeFile = item.get("file"); + boolean hasElement = maybeElement instanceof String; + boolean hasFile = maybeFile instanceof String; + if (hasElement && hasFile) { + message.append('(') + .append(maybeElement) + .append(", ") + .append(maybeFile) + .append(')'); + } else if (hasElement) { + message.append('(') + .append(maybeElement) + .append(')'); + } else if (hasFile) { + message.append('(') + .append(maybeFile) + .append(')'); + } + + if (i > 0) { + message.append(" > "); + } + } + + return message.toString(); + } + + @TestOnly + public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + final Double startTimestamp = breadcrumb.getData("start_timestamp") instanceof Number + ? (Double) breadcrumb.getData("start_timestamp") : null; + final Double endTimestamp = breadcrumb.getData("end_timestamp") instanceof Number + ? (Double) breadcrumb.getData("end_timestamp") : null; + final String url = breadcrumb.getData("url") instanceof String + ? (String) breadcrumb.getData("url") : null; + + if (startTimestamp == null || endTimestamp == null || url == null) { + return null; + } + + final HashMap data = new HashMap<>(); + if (breadcrumb.getData("method") instanceof String) { + data.put("method", breadcrumb.getData("method")); + } + if (breadcrumb.getData("status_code") instanceof Double) { + final Double statusCode = (Double) breadcrumb.getData("status_code"); + if (statusCode > 0) { + data.put("statusCode", statusCode.intValue()); + } + } + if (breadcrumb.getData("request_body_size") instanceof Double) { + data.put("requestBodySize", breadcrumb.getData("request_body_size")); + } + if (breadcrumb.getData("response_body_size") instanceof Double) { + data.put("responseBodySize", breadcrumb.getData("response_body_size")); + } + + final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent(); + rrWebSpanEvent.setOp("resource.http"); + rrWebSpanEvent.setStartTimestamp(startTimestamp / 1000.0); + rrWebSpanEvent.setEndTimestamp(endTimestamp / 1000.0); + rrWebSpanEvent.setDescription(url); + rrWebSpanEvent.setData(data); + return rrWebSpanEvent; + } + + private void setRRWebEventDefaultsFrom(final @NotNull RRWebBreadcrumbEvent rrWebBreadcrumb, final @NotNull Breadcrumb breadcrumb) { + rrWebBreadcrumb.setLevel(breadcrumb.getLevel()); + rrWebBreadcrumb.setData(breadcrumb.getData()); + rrWebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime()); + rrWebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0); + rrWebBreadcrumb.setBreadcrumbType("default"); + } +} diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 78dfa4fa5..3d585b6b1 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -158,4 +158,14 @@ public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android return null; } + + @Override + public void captureReplay(boolean isHardCrash, Promise promise) { + this.impl.captureReplay(isHardCrash, promise); + } + + @Override + public String getCurrentReplayId() { + return this.impl.getCurrentReplayId(); + } } diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 1a11e8571..33fa7283b 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -158,4 +158,14 @@ public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android return null; } + + @ReactMethod + public void captureReplay(boolean isHardCrash, Promise promise) { + this.impl.captureReplay(isHardCrash, promise); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public String getCurrentReplayId() { + return this.impl.getCurrentReplayId(); + } } diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index 3f0ccc9b3..3abfe7c4d 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -38,6 +38,10 @@ #import "RNSentryEvents.h" #import "RNSentryDependencyContainer.h" +#if SENTRY_TARGET_REPLAY_SUPPORTED +#import "RNSentryReplay.h" +#endif + #if SENTRY_HAS_UIKIT #import "RNSentryRNSScreen.h" #import "RNSentryFramesTrackerListener.h" @@ -106,6 +110,10 @@ + (BOOL)requiresMainQueueSetup { sentHybridSdkDidBecomeActive = true; } +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay postInit]; +#endif + resolve(@YES); } @@ -117,7 +125,6 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) // Because we sent it already before the app crashed. if (nil != event.exceptions.firstObject.type && [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location != NSNotFound) { - NSLog(@"Unhandled JS Exception"); return nil; } @@ -136,6 +143,10 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) [mutableOptions removeObjectForKey:@"tracesSampler"]; [mutableOptions removeObjectForKey:@"enableTracing"]; +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay updateOptions:mutableOptions]; +#endif + SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions didFailWithError:errorPointer]; if (*errorPointer != nil) { return nil; @@ -617,6 +628,27 @@ - (NSDictionary*) fetchNativeStackFramesBy: (NSArray*)instructionsAdd // the 'tracesSampleRate' or 'tracesSampler' option. } +RCT_EXPORT_METHOD(captureReplay: (BOOL)isHardCrash + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + [PrivateSentrySDKOnly captureReplay]; + resolve([PrivateSentrySDKOnly getReplayId]); +#else + resolve(nil); +#endif +} + +RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, getCurrentReplayId) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + return [PrivateSentrySDKOnly getReplayId]; +#else + return nil; +#endif +} + static NSString* const enabledProfilingMessage = @"Enable Hermes to use Sentry Profiling."; static SentryId* nativeProfileTraceId = nil; static uint64_t nativeProfileStartTime = 0; diff --git a/ios/RNSentryReplay.h b/ios/RNSentryReplay.h new file mode 100644 index 000000000..f3cb5a6ef --- /dev/null +++ b/ios/RNSentryReplay.h @@ -0,0 +1,8 @@ + +@interface RNSentryReplay : NSObject + ++ (void)updateOptions:(NSMutableDictionary *)options; + ++ (void)postInit; + +@end diff --git a/ios/RNSentryReplay.m b/ios/RNSentryReplay.m new file mode 100644 index 000000000..369388819 --- /dev/null +++ b/ios/RNSentryReplay.m @@ -0,0 +1,72 @@ +#import "RNSentryReplay.h" +#import "RNSentryReplayBreadcrumbConverter.h" + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation RNSentryReplay { +} + ++ (void)updateOptions:(NSMutableDictionary *)options { + NSDictionary *experiments = options[@"_experiments"]; + [options removeObjectForKey:@"_experiments"]; + if (experiments == nil) { + NSLog(@"Session replay disabled via configuration"); + return; + } + + if (experiments[@"replaysSessionSampleRate"] == nil && + experiments[@"replaysOnErrorSampleRate"] == nil) { + NSLog(@"Session replay disabled via configuration"); + return; + } + + NSLog(@"Setting up session replay"); + NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{}; + + [options setValue:@{ + @"sessionReplay" : @{ + @"sessionSampleRate" : experiments[@"replaysSessionSampleRate"] + ?: [NSNull null], + @"errorSampleRate" : experiments[@"replaysOnErrorSampleRate"] + ?: [NSNull null], + @"redactAllImages" : replayOptions[@"maskAllImages"] ?: [NSNull null], + @"redactAllText" : replayOptions[@"maskAllText"] ?: [NSNull null], + } + } + forKey:@"experimental"]; + + [RNSentryReplay addReplayRNRedactClasses:replayOptions]; +} + ++ (void)addReplayRNRedactClasses:(NSDictionary *_Nullable)replayOptions { + NSMutableArray *_Nonnull classesToRedact = [[NSMutableArray alloc] init]; + if ([replayOptions[@"maskAllVectors"] boolValue] == YES) { + Class _Nullable maybeRNSVGViewClass = NSClassFromString(@"RNSVGSvgView"); + if (maybeRNSVGViewClass != nil) { + [classesToRedact addObject:maybeRNSVGViewClass]; + } + } + if ([replayOptions[@"maskAllImages"] boolValue] == YES) { + Class _Nullable maybeRCTImageClass = NSClassFromString(@"RCTImageView"); + if (maybeRCTImageClass != nil) { + [classesToRedact addObject:maybeRCTImageClass]; + } + } + if ([replayOptions[@"maskAllText"] boolValue] == YES) { + Class _Nullable maybeRCTTextClass = NSClassFromString(@"RCTTextView"); + if (maybeRCTTextClass != nil) { + [classesToRedact addObject:maybeRCTTextClass]; + } + } + [PrivateSentrySDKOnly addReplayRedactClasses:classesToRedact]; +} + ++ (void)postInit { + RNSentryReplayBreadcrumbConverter *breadcrumbConverter = + [[RNSentryReplayBreadcrumbConverter alloc] init]; + [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter + screenshotProvider:nil]; +} + +@end +#endif diff --git a/ios/RNSentryReplayBreadcrumbConverter.h b/ios/RNSentryReplayBreadcrumbConverter.h new file mode 100644 index 000000000..ce12fcf39 --- /dev/null +++ b/ios/RNSentryReplayBreadcrumbConverter.h @@ -0,0 +1,16 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface RNSentryReplayBreadcrumbConverter + : NSObject + +- (instancetype _Nonnull)init; + ++ (NSString* _Nullable) getTouchPathMessageFrom:(NSArray* _Nullable) path; + +- (id _Nullable)convertFrom:(SentryBreadcrumb *_Nonnull) breadcrumb; + +@end +#endif diff --git a/ios/RNSentryReplayBreadcrumbConverter.m b/ios/RNSentryReplayBreadcrumbConverter.m new file mode 100644 index 000000000..251ada890 --- /dev/null +++ b/ios/RNSentryReplayBreadcrumbConverter.m @@ -0,0 +1,168 @@ +#import "RNSentryReplayBreadcrumbConverter.h" + +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation RNSentryReplayBreadcrumbConverter { + SentrySRDefaultBreadcrumbConverter *defaultConverter; +} + +- (instancetype _Nonnull)init { + if (self = [super init]) { + self->defaultConverter = + [SentrySessionReplayIntegration createDefaultBreadcrumbConverter]; + } + return self; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb { + assert(breadcrumb.timestamp != nil); + + if ([breadcrumb.category isEqualToString:@"sentry.event"] || + [breadcrumb.category isEqualToString:@"sentry.transaction"]) { + // Do not add Sentry Event breadcrumbs to replay + return nil; + } + + if ([breadcrumb.category isEqualToString:@"http"]) { + // Drop native network breadcrumbs to avoid duplicates + return nil; + } + + if ([breadcrumb.category isEqualToString:@"touch"]) { + return [self convertTouch:breadcrumb]; + } + + if ([breadcrumb.category isEqualToString:@"navigation"]) { + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:breadcrumb.category + message:nil + level:breadcrumb.level + data:breadcrumb.data]; + } + + if ([breadcrumb.category isEqualToString:@"xhr"]) { + return [self convertNavigation:breadcrumb]; + } + + SentryRRWebEvent *nativeBreadcrumb = + [self->defaultConverter convertFrom:breadcrumb]; + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb && nativeBreadcrumb.data && + nativeBreadcrumb.data[@"payload"] && + nativeBreadcrumb.data[@"payload"][@"category"] && + [nativeBreadcrumb.data[@"payload"][@"category"] + isEqualToString:@"navigation"]) { + return nil; + } + + return nativeBreadcrumb; +} + +- (id _Nullable) convertTouch:(SentryBreadcrumb *_Nonnull)breadcrumb { + if (breadcrumb.data == nil) { + return nil; + } + + NSMutableArray *path = [breadcrumb.data valueForKey:@"path"]; + NSString* message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:path]; + + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:@"ui.tap" + message:message + level:breadcrumb.level + data:breadcrumb.data]; +} + ++ (NSString* _Nullable) getTouchPathMessageFrom:(NSArray* _Nullable) path { + if (path == nil) { + return nil; + } + + NSInteger pathCount = [path count]; + if (pathCount <= 0) { + return nil; + } + + NSMutableString *message = [[NSMutableString alloc] init]; + for (NSInteger i = MIN(3, pathCount - 1); i >= 0; i--) { + NSDictionary *item = [path objectAtIndex:i]; + if (item == nil) { + return nil; // There should be no nil (undefined) from JS, but to be safe we check it here + } + + id name = [item objectForKey:@"name"]; + id label = [item objectForKey:@"label"]; + BOOL hasName = [name isKindOfClass:[NSString class]]; + BOOL hasLabel = [label isKindOfClass:[NSString class]]; + if (!hasName && !hasLabel) { + return nil; // This again should never be allowed in JS, but to be safe we check it here + } + if (hasLabel) { + [message appendString:(NSString *)label]; + } else if (hasName) { + [message appendString:(NSString *)name]; + } + + id element = [item objectForKey:@"element"]; + id file = [item objectForKey:@"file"]; + BOOL hasElement = [element isKindOfClass:[NSString class]]; + BOOL hasFile = [file isKindOfClass:[NSString class]]; + if (hasElement && hasFile) { + [message appendFormat:@"(%@, %@)", (NSString *)element, (NSString *)file]; + } else if (hasElement) { + [message appendFormat:@"(%@)", (NSString *)element]; + } else if (hasFile) { + [message appendFormat:@"(%@)", (NSString *)file]; + } + + if (i > 0) { + [message appendString:@" > "]; + } + } + + return message; +} + +- (id _Nullable)convertNavigation: (SentryBreadcrumb *_Nonnull)breadcrumb { + NSNumber* startTimestamp = [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"start_timestamp"] : nil; + NSNumber* endTimestamp = [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"end_timestamp"] : nil; + NSString* url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]] + ? breadcrumb.data[@"url"] : nil; + + if (startTimestamp == nil || endTimestamp == nil || url == nil) { + return nil; + } + + NSMutableDictionary* data = [[NSMutableDictionary alloc] init]; + if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) { + data[@"method"] = breadcrumb.data[@"method"]; + } + if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) { + data[@"statusCode"] = breadcrumb.data[@"status_code"]; + } + if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"]; + } + if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; + } + + return [SentrySessionReplayIntegration + createNetworkBreadcrumbWithTimestamp:[NSDate dateWithTimeIntervalSince1970:(startTimestamp.doubleValue / 1000)] + endTimestamp:[NSDate dateWithTimeIntervalSince1970:(endTimestamp.doubleValue / 1000)] + operation:@"resource.http" + description:url + data:data]; +} + +@end + +#endif diff --git a/package.json b/package.json index 5eaed0a5b..4d1c872d8 100644 --- a/package.json +++ b/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": "5.25.0", + "version": "5.26.0-alpha.3", "description": "Official Sentry SDK for react-native", "typings": "dist/js/index.d.ts", "types": "dist/js/index.d.ts", @@ -67,6 +67,7 @@ "react-native": ">=0.65.0" }, "dependencies": { + "@sentry/babel-plugin-component-annotate": "2.20.1", "@sentry/browser": "7.117.0", "@sentry/cli": "2.31.2", "@sentry/core": "7.117.0", diff --git a/samples/expo/app.json b/samples/expo/app.json index 50854065e..48b1af612 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -4,7 +4,7 @@ "slug": "sentry-react-native-expo-sample", "jsEngine": "hermes", "scheme": "sentry-expo-sample", - "version": "5.25.0", + "version": "5.26.0-alpha.3", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -19,7 +19,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", - "buildNumber": "12" + "buildNumber": "13" }, "android": { "adaptiveIcon": { @@ -27,7 +27,7 @@ "backgroundColor": "#ffffff" }, "package": "io.sentry.expo.sample", - "versionCode": 12 + "versionCode": 13 }, "web": { "bundler": "metro", diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 6fac65744..094ea7cba 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -9,12 +9,15 @@ import { HttpClient } from '@sentry/integrations'; import { SENTRY_INTERNAL_DSN } from '../utils/dsn'; import * as Sentry from '@sentry/react-native'; import { isExpoGo } from '../utils/isExpoGo'; +import { LogBox } from 'react-native'; export { // Catch any errors thrown by the Layout component. ErrorBoundary, } from 'expo-router'; +LogBox.ignoreAllLogs(); + // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); @@ -78,6 +81,8 @@ process.env.EXPO_SKIP_DURING_EXPORT !== 'true' && Sentry.init({ // dist: `1`, _experiments: { profilesSampleRate: 0, + // replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 1.0, }, enableSpotlight: true, }); diff --git a/samples/expo/babel.config.js b/samples/expo/babel.config.js index 7a1387231..3a495f8f7 100644 --- a/samples/expo/babel.config.js +++ b/samples/expo/babel.config.js @@ -1,5 +1,3 @@ -const componentAnnotatePlugin = require('@sentry/babel-plugin-component-annotate'); - module.exports = function (api) { api.cache(false); return { @@ -13,7 +11,6 @@ module.exports = function (api) { }, }, ], - componentAnnotatePlugin, ], }; }; diff --git a/samples/expo/metro.config.js b/samples/expo/metro.config.js index b79a919dd..d8f5aa467 100644 --- a/samples/expo/metro.config.js +++ b/samples/expo/metro.config.js @@ -9,6 +9,7 @@ const config = getSentryExpoConfig(__dirname, { // [Web-only]: Enables CSS support in Metro. isCSSEnabled: true, getDefaultConfig, + annotateReactComponents: true, }); config.watchFolders.push(path.resolve(__dirname, '../../node_modules/@sentry')); diff --git a/samples/expo/package.json b/samples/expo/package.json index cbb1b30dc..e7f00509d 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-expo-sample", - "version": "5.25.0", + "version": "5.26.0-alpha.3", "main": "expo-router/entry", "scripts": { "start": "expo start", diff --git a/samples/expo/utils/setScopeProperties.ts b/samples/expo/utils/setScopeProperties.ts index a461ba469..ec68866a5 100644 --- a/samples/expo/utils/setScopeProperties.ts +++ b/samples/expo/utils/setScopeProperties.ts @@ -76,5 +76,12 @@ export const setScopeProperties = () => { category: 'TEST-CATEGORY', }); + console.log('This is a console log message.'); + console.info('This is a console info message.'); + console.warn('This is a console warn message.'); + console.error('This is a console error message.'); + console.debug('This is a console debug message.'); + console.trace('This is a console trace message.'); + console.log('Test scope properties were set.'); }; diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index c6290b2dc..824741db3 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -134,8 +134,8 @@ android { applicationId "io.sentry.reactnative.sample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 19 - versionName "5.25.0" + versionCode 16 + versionName "5.26.0-alpha.3" } signingConfigs { diff --git a/samples/react-native/babel.config.js b/samples/react-native/babel.config.js index 8ad75b81a..8c8fb9c1a 100644 --- a/samples/react-native/babel.config.js +++ b/samples/react-native/babel.config.js @@ -1,5 +1,3 @@ -const componentAnnotatePlugin = require('@sentry/babel-plugin-component-annotate'); - module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ @@ -11,6 +9,6 @@ module.exports = { }, }, ], - componentAnnotatePlugin, + 'react-native-reanimated/plugin', ], }; diff --git a/samples/react-native/ios/sentryreactnativesample/Info.plist b/samples/react-native/ios/sentryreactnativesample/Info.plist index 1e40a1a18..7ced2340d 100644 --- a/samples/react-native/ios/sentryreactnativesample/Info.plist +++ b/samples/react-native/ios/sentryreactnativesample/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 5.25.0 + 5.26.0 CFBundleSignature ???? CFBundleVersion - 19 + 20 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist index 9b9faf2ab..e667a5550 100644 --- a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist +++ b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 5.25.0 + 5.26.0 CFBundleSignature ???? CFBundleVersion - 19 + 20 diff --git a/samples/react-native/metro.config.js b/samples/react-native/metro.config.js index 10d30549b..9e1e8c7f1 100644 --- a/samples/react-native/metro.config.js +++ b/samples/react-native/metro.config.js @@ -60,4 +60,6 @@ const config = { }; const m = mergeConfig(getDefaultConfig(__dirname), config); -module.exports = withSentryConfig(m); +module.exports = withSentryConfig(m, { + annotateReactComponents: true, +}); diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 0c5464099..08b45d5ec 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-sample", - "version": "5.25.0", + "version": "5.26.0-alpha.3", "private": true, "scripts": { "postinstall": "patch-package", @@ -29,8 +29,10 @@ "react-native": "0.73.2", "react-native-gesture-handler": "^2.14.0", "react-native-macos": "^0.73.0-0", + "react-native-reanimated": "3.8.1", "react-native-safe-area-context": "4.8.0", "react-native-screens": "3.29.0", + "react-native-svg": "^15.3.0", "react-native-vector-icons": "^10.0.3", "react-redux": "^8.1.3", "redux": "^4.2.1" diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index ad7edc5a2..338a3cef9 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -5,8 +5,14 @@ import { } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createStackNavigator } from '@react-navigation/stack'; - import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; // Import the Sentry React Native SDK import * as Sentry from '@sentry/react-native'; @@ -22,9 +28,13 @@ import { Provider } from 'react-redux'; import { store } from './reduxApp'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import GesturesTracingScreen from './Screens/GesturesTracingScreen'; -import { Platform, StyleSheet } from 'react-native'; +import { LogBox, Platform, StyleSheet, View } from 'react-native'; import { HttpClient } from '@sentry/integrations'; import Ionicons from 'react-native-vector-icons/Ionicons'; +import PlaygroundScreen from './Screens/PlaygroundScreen'; +import { logWithoutTracing } from './utils'; + +LogBox.ignoreAllLogs(); const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios'; @@ -40,16 +50,19 @@ Sentry.init({ debug: true, environment: 'dev', beforeSend: (event: Sentry.Event) => { - console.log('Event beforeSend:', event.event_id); + logWithoutTracing('Event beforeSend:', event.event_id); return event; }, beforeSendTransaction(event) { - console.log('Transaction beforeSend:', event.event_id); + logWithoutTracing('Transaction beforeSend:', event.event_id); return event; }, // This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted. onReady: ({ didCallNativeInit }) => { - console.log('onReady called with didCallNativeInit:', didCallNativeInit); + logWithoutTracing( + 'onReady called with didCallNativeInit:', + didCallNativeInit, + ); }, integrations(integrations) { integrations.push( @@ -79,6 +92,11 @@ Sentry.init({ failedRequestTargets: [/.*/], }), Sentry.metrics.metricsAggregatorIntegration(), + Sentry.mobileReplayIntegration({ + maskAllImages: true, + maskAllVectors: true, + // maskAllText: false, + }), ); return integrations.filter(i => i.name !== 'Dedupe'); }, @@ -102,6 +120,8 @@ Sentry.init({ // dist: `1`, _experiments: { profilesSampleRate: 1.0, + // replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, }, enableSpotlight: true, }); @@ -203,15 +223,76 @@ function BottomTabs() { ), }} /> + ( + + ), + }} + /> + ); } +function RunningIndicator() { + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + return null; + } + + return ; +} + +function RotatingBox() { + const sv = useSharedValue(0); + + React.useEffect(() => { + sv.value = withRepeat( + withTiming(360, { + duration: 1_000_000, + easing: Easing.linear, + }), + -1, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${sv.value * 360}deg` }], + })); + + return ( + + + + ); +} + const styles = StyleSheet.create({ wrapper: { flex: 1, }, + container: { + position: 'absolute', + left: 30, + top: 30, + }, + box: { + height: 50, + width: 50, + backgroundColor: '#b58df1', + borderRadius: 5, + }, }); export default Sentry.wrap(BottomTabs); diff --git a/samples/react-native/src/Screens/PlaygroundScreen.tsx b/samples/react-native/src/Screens/PlaygroundScreen.tsx new file mode 100644 index 000000000..7a5d21289 --- /dev/null +++ b/samples/react-native/src/Screens/PlaygroundScreen.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { + View, + StyleSheet, + Text, + TextInput, + Image, + ImageBackground, + TouchableWithoutFeedback, + KeyboardAvoidingView, + Keyboard, + ScrollView, + SafeAreaView, + Pressable, +} from 'react-native'; +import SvgGraphic from '../components/SvgGraphic'; + +const multilineText = `This +is +a +multiline +input +text +`; + +const PlaygroundScreen = () => { + return ( + + + + + + Text: + {'This is '} + + TextInput: + + + Image: + + + BackgroundImage: + + + This text should be over the image. + + + Pressable: + { + event.stopPropagation(); + event.preventDefault(); + console.log('Pressable pressed'); + }}> + Press me + + react-native-svg + + + + + + + ); +}; + +export default PlaygroundScreen; + +const styles = StyleSheet.create({ + space: { + marginBottom: 50, + }, + container: { + padding: 5, + flex: 1, + }, + image: { + width: 200, + height: 200, + }, + backgroundImageContainer: { + width: 200, + height: 200, + }, + textInputStyle: { + height: 200, + borderColor: 'gray', + borderWidth: 1, + }, +}); diff --git a/samples/react-native/src/Screens/TrackerScreen.tsx b/samples/react-native/src/Screens/TrackerScreen.tsx index 19f3a6783..7716fff65 100644 --- a/samples/react-native/src/Screens/TrackerScreen.tsx +++ b/samples/react-native/src/Screens/TrackerScreen.tsx @@ -69,15 +69,11 @@ const TrackerScreen = () => { (state === 'loaded' && 'Loaded') || 'Unknown'; const shouldRecordFullDisplay = state === 'loaded' || state === 'error'; - console.log('shouldRecordFullDisplay', shouldRecordFullDisplay); - console.log('statusText', statusText); return ( - - Global COVID19 Cases - + {cases ? ( <> @@ -113,6 +109,12 @@ const TrackerScreen = () => { ); }; +const TrackerTitle = () => ( + + Global COVID19 Cases + +); + export default Sentry.withProfiler(TrackerScreen); const Statistic = (props: { diff --git a/samples/react-native/src/components/SvgGraphic.tsx b/samples/react-native/src/components/SvgGraphic.tsx new file mode 100644 index 000000000..525c7cccc --- /dev/null +++ b/samples/react-native/src/components/SvgGraphic.tsx @@ -0,0 +1,288 @@ +import * as React from 'react'; +import Svg, { SvgProps, Defs, G, Path, Ellipse } from 'react-native-svg'; + +const SvgComponent = (props: SvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default SvgComponent; diff --git a/samples/react-native/src/setScopeProperties.ts b/samples/react-native/src/setScopeProperties.ts index a461ba469..ec68866a5 100644 --- a/samples/react-native/src/setScopeProperties.ts +++ b/samples/react-native/src/setScopeProperties.ts @@ -76,5 +76,12 @@ export const setScopeProperties = () => { category: 'TEST-CATEGORY', }); + console.log('This is a console log message.'); + console.info('This is a console info message.'); + console.warn('This is a console warn message.'); + console.error('This is a console error message.'); + console.debug('This is a console debug message.'); + console.trace('This is a console trace message.'); + console.log('Test scope properties were set.'); }; diff --git a/samples/react-native/src/utils.ts b/samples/react-native/src/utils.ts new file mode 100644 index 000000000..8681333e3 --- /dev/null +++ b/samples/react-native/src/utils.ts @@ -0,0 +1,7 @@ +export function logWithoutTracing(...args: unknown[]) { + if ('__sentry_original__' in console.log) { + console.log.__sentry_original__(...args); + } else { + console.log(...args); + } +} diff --git a/samples/react-native/yarn.lock b/samples/react-native/yarn.lock index efba588fb..0c5340d4c 100644 --- a/samples/react-native/yarn.lock +++ b/samples/react-native/yarn.lock @@ -32,6 +32,14 @@ dependencies: "@babel/highlight" "^7.22.5" +"@babel/code-frame@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" + integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== + dependencies: + "@babel/highlight" "^7.24.7" + picocolors "^1.0.0" + "@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8", "@babel/compat-data@^7.19.3": version "7.19.3" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.3.tgz#707b939793f867f5a73b2666e6d9a3396eb03151" @@ -147,6 +155,16 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" +"@babel/generator@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" + integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== + dependencies: + "@babel/types" "^7.24.7" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" @@ -161,6 +179,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-annotate-as-pure@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz#5373c7bc8366b12a033b4be1ac13a206c6656aab" + integrity sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-builder-binary-assignment-operator-visitor@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.5.tgz#a3f4758efdd0190d8927fcffd261755937c71878" @@ -243,6 +268,21 @@ "@babel/helper-split-export-declaration" "^7.22.5" semver "^6.3.0" +"@babel/helper-create-class-features-plugin@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.7.tgz#2eaed36b3a1c11c53bdf80d53838b293c52f5b3b" + integrity sha512-kTkaDl7c9vO80zeX1rJxnuRpEsD5tA81yh11X1gQo+PhSti3JS+7qeZo9U4RHobKRiFPKaGK3svUAeb8D0Q7eg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.24.7" + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-function-name" "^7.24.7" + "@babel/helper-member-expression-to-functions" "^7.24.7" + "@babel/helper-optimise-call-expression" "^7.24.7" + "@babel/helper-replace-supers" "^7.24.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + semver "^6.3.1" + "@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.19.0": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz#7976aca61c0984202baca73d84e2337a5424a41b" @@ -299,6 +339,13 @@ resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98" integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q== +"@babel/helper-environment-visitor@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" + integrity sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" @@ -315,6 +362,14 @@ "@babel/template" "^7.22.5" "@babel/types" "^7.22.5" +"@babel/helper-function-name@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz#75f1e1725742f39ac6584ee0b16d94513da38dd2" + integrity sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA== + dependencies: + "@babel/template" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-hoist-variables@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" @@ -329,6 +384,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-hoist-variables@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz#b4ede1cde2fd89436397f30dc9376ee06b0f25ee" + integrity sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-member-expression-to-functions@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815" @@ -350,6 +412,14 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-member-expression-to-functions@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.7.tgz#67613d068615a70e4ed5101099affc7a41c5225f" + integrity sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-module-imports@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" @@ -364,6 +434,14 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-module-imports@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz#f2f980392de5b84c3328fc71d38bd81bbb83042b" + integrity sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.0": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" @@ -406,6 +484,17 @@ "@babel/traverse" "^7.22.5" "@babel/types" "^7.22.5" +"@babel/helper-module-transforms@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz#31b6c9a2930679498db65b685b1698bfd6c7daf8" + integrity sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ== + dependencies: + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-module-imports" "^7.24.7" + "@babel/helper-simple-access" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" @@ -420,6 +509,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-optimise-call-expression@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz#8b0a0456c92f6b323d27cfd00d1d664e76692a0f" + integrity sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" @@ -435,6 +531,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295" integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg== +"@babel/helper-plugin-utils@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz#98c84fe6fe3d0d3ae7bfc3a5e166a46844feb2a0" + integrity sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg== + "@babel/helper-remap-async-to-generator@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519" @@ -487,6 +588,15 @@ "@babel/helper-member-expression-to-functions" "^7.22.15" "@babel/helper-optimise-call-expression" "^7.22.5" +"@babel/helper-replace-supers@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.24.7.tgz#f933b7eed81a1c0265740edc91491ce51250f765" + integrity sha512-qTAxxBM81VEyoAY0TtLrx1oAEJc09ZK67Q9ljQToqCnA+55eNwCORaxlKyu+rNfX86o8OXRUSNUnrtsAZXM9sg== + dependencies: + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-member-expression-to-functions" "^7.24.7" + "@babel/helper-optimise-call-expression" "^7.24.7" + "@babel/helper-simple-access@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" @@ -508,6 +618,14 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-simple-access@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz#bcade8da3aec8ed16b9c4953b74e506b51b5edb3" + integrity sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-skip-transparent-expression-wrappers@^7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz#778d87b3a758d90b471e7b9918f34a9a02eb5818" @@ -522,6 +640,14 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-skip-transparent-expression-wrappers@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz#5f8fa83b69ed5c27adc56044f8be2b3ea96669d9" + integrity sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-split-export-declaration@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" @@ -543,6 +669,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-split-export-declaration@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" + integrity sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-string-parser@^7.18.10": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" @@ -558,6 +691,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== +"@babel/helper-string-parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" + integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== + "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" @@ -573,6 +711,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193" integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== +"@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== + "@babel/helper-validator-option@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" @@ -583,6 +726,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz#de52000a15a177413c8234fa3a8af4ee8102d0ac" integrity sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw== +"@babel/helper-validator-option@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" + integrity sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw== + "@babel/helper-wrap-function@^7.18.9": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz#89f18335cff1152373222f76a4b37799636ae8b1" @@ -648,6 +796,16 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.13.16", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.19.3": version "7.19.3" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.3.tgz#8dd36d17c53ff347f9e55c328710321b49479a9a" @@ -663,6 +821,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.5.tgz#721fd042f3ce1896238cf1b341c77eb7dee7dbea" integrity sha512-DFZMC9LJUG9PLOclRC32G63UXwzqS2koQC8dkx+PLdmt1xSePYpbT/NbsrJy8Q/muXz7o/h/d4A7Fuyixm559Q== +"@babel/parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" + integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz#87245a21cd69a73b0b81bcda98d443d6df08f05e" @@ -880,6 +1043,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" +"@babel/plugin-syntax-jsx@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz#39a1fa4a7e3d3d7f34e2acc6be585b718d30e02d" + integrity sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -943,6 +1113,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" +"@babel/plugin-syntax-typescript@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz#58d458271b4d3b6bb27ee6ac9525acbb259bad1c" + integrity sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-syntax-typescript@^7.7.2": version "7.20.0" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz#4e9a0cfc769c85689b77a2e642d24e9f697fc8c7" @@ -965,6 +1142,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" +"@babel/plugin-transform-arrow-functions@^7.0.0-0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz#4f6886c11e423bd69f3ce51dbf42424a5f275514" + integrity sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-transform-arrow-functions@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz#e5ba566d0c58a5b2ba2a8b795450641950b71958" @@ -1261,6 +1445,15 @@ "@babel/helper-plugin-utils" "^7.22.5" "@babel/helper-simple-access" "^7.22.5" +"@babel/plugin-transform-modules-commonjs@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.7.tgz#9fd5f7fdadee9085886b183f1ad13d1ab260f4ab" + integrity sha512-iFI8GDxtevHJ/Z22J5xQpVqFLlMNstcLXh994xifFwxxGslr2ZXXLWgtBeLctOD63UFDArdvN6Tg8RFw+aEmjQ== + dependencies: + "@babel/helper-module-transforms" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-simple-access" "^7.24.7" + "@babel/plugin-transform-modules-systemjs@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz#18c31410b5e579a0092638f95c896c2a98a5d496" @@ -1302,6 +1495,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" +"@babel/plugin-transform-nullish-coalescing-operator@^7.0.0-0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz#1de4534c590af9596f53d67f52a92f12db984120" + integrity sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-transform-nullish-coalescing-operator@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz#f8872c65776e0b552e0849d7596cddd416c3e381" @@ -1353,6 +1554,15 @@ "@babel/helper-plugin-utils" "^7.22.5" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" +"@babel/plugin-transform-optional-chaining@^7.0.0-0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.7.tgz#b8f6848a80cf2da98a8a204429bec04756c6d454" + integrity sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-transform-optional-chaining@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.5.tgz#1003762b9c14295501beb41be72426736bedd1e0" @@ -1491,6 +1701,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" +"@babel/plugin-transform-shorthand-properties@^7.0.0-0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz#85448c6b996e122fa9e289746140aaa99da64e73" + integrity sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-transform-shorthand-properties@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz#6e277654be82b5559fc4b9f58088507c24f0c624" @@ -1535,6 +1752,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.9" +"@babel/plugin-transform-template-literals@^7.0.0-0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz#a05debb4a9072ae8f985bcf77f3f215434c8f8c8" + integrity sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-transform-template-literals@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz#8f38cf291e5f7a8e60e9f733193f0bcc10909bff" @@ -1558,6 +1782,16 @@ "@babel/helper-plugin-utils" "^7.19.0" "@babel/plugin-syntax-typescript" "^7.18.6" +"@babel/plugin-transform-typescript@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.7.tgz#b006b3e0094bf0813d505e0c5485679eeaf4a881" + integrity sha512-iLD3UNkgx2n/HrjBesVbYX6j0yqn/sJktvbtKKgcaLIQ4bTTQ8obAypc1VpyHPD2y4Phh9zHOaAt8e/L14wCpw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.24.7" + "@babel/helper-create-class-features-plugin" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/plugin-syntax-typescript" "^7.24.7" + "@babel/plugin-transform-unicode-escapes@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.5.tgz#ce0c248522b1cb22c7c992d88301a5ead70e806c" @@ -1712,6 +1946,17 @@ "@babel/helper-validator-option" "^7.18.6" "@babel/plugin-transform-typescript" "^7.18.6" +"@babel/preset-typescript@^7.16.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz#66cd86ea8f8c014855671d5ea9a737139cbbfef1" + integrity sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ== + dependencies: + "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-validator-option" "^7.24.7" + "@babel/plugin-syntax-jsx" "^7.24.7" + "@babel/plugin-transform-modules-commonjs" "^7.24.7" + "@babel/plugin-transform-typescript" "^7.24.7" + "@babel/register@^7.13.16": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.18.9.tgz#1888b24bc28d5cc41c412feb015e9ff6b96e439c" @@ -1776,6 +2021,15 @@ "@babel/parser" "^7.22.5" "@babel/types" "^7.22.5" +"@babel/template@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" + integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/traverse@^7.19.0", "@babel/traverse@^7.19.1", "@babel/traverse@^7.19.3", "@babel/traverse@^7.7.4": version "7.19.3" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.3.tgz#3a3c5348d4988ba60884e8494b0592b2f15a04b4" @@ -1824,6 +2078,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" + integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.24.7" + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-function-name" "^7.24.7" + "@babel/helper-hoist-variables" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" + debug "^4.3.1" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.19.3", "@babel/types@^7.3.0", "@babel/types@^7.3.3": version "7.19.3" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.3.tgz#fc420e6bbe54880bce6779ffaf315f5e43ec9624" @@ -1860,6 +2130,15 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@babel/types@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" + integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== + dependencies: + "@babel/helper-string-parser" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + "@babel/types@^7.4.4": version "7.19.4" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.4.tgz#0dd5c91c573a202d600490a35b33246fed8a41c7" @@ -2238,6 +2517,15 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.9" +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3": version "3.1.0" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" @@ -2253,6 +2541,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + "@jridgewell/source-map@^0.3.2": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" @@ -2295,6 +2588,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/trace-mapping@^0.3.9": version "0.3.15" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774" @@ -3983,6 +4284,11 @@ bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -4111,7 +4417,7 @@ caniuse-lite@^1.0.30001503: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001506.tgz#35bd814b310a487970c585430e9e80ee23faf14b" integrity sha512-6XNEcpygZMCKaufIcgpQNZNf00GEqc7VQON+9Rd0K1bMYo8xhMZRAo5zpbnbMNizi4YNgIDAFrdykWsvY3H4Hw== -chalk@^2.0.0: +chalk@^2.0.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -4401,6 +4707,30 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + csstype@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" @@ -4425,6 +4755,13 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: dependencies: ms "2.1.2" +debug@^4.3.1: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -4539,6 +4876,36 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -4569,6 +4936,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + envinfo@^7.10.0: version "7.11.0" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.11.0.tgz#c3793f44284a55ff8c82faf1ffd91bc6478ea01f" @@ -6526,6 +6898,11 @@ marky@^1.2.2: resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q== +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" @@ -7073,6 +7450,13 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -7575,6 +7959,20 @@ react-native-macos@^0.73.0-0: ws "^6.2.2" yargs "^17.6.2" +react-native-reanimated@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.8.1.tgz#45c13d4bedebef8df3d5a8756f25072de65960d7" + integrity sha512-EdM0vr3JEaNtqvstqESaPfOBy0gjYBkr1iEolWJ82Ax7io8y9OVUIphgsLKTB36CtR1XtmBw0RZVj7KArc7ZVA== + dependencies: + "@babel/plugin-transform-arrow-functions" "^7.0.0-0" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.0.0-0" + "@babel/plugin-transform-optional-chaining" "^7.0.0-0" + "@babel/plugin-transform-shorthand-properties" "^7.0.0-0" + "@babel/plugin-transform-template-literals" "^7.0.0-0" + "@babel/preset-typescript" "^7.16.7" + convert-source-map "^2.0.0" + invariant "^2.2.4" + react-native-safe-area-context@4.8.0: version "4.8.0" resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-4.8.0.tgz#9fce29095b11deeead8da0abce32ee729fb3eb41" @@ -7588,6 +7986,14 @@ react-native-screens@3.29.0: react-freeze "^1.0.0" warn-once "^0.1.0" +react-native-svg@^15.3.0: + version "15.3.0" + resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.3.0.tgz#e24b833fe330714c99f1dd894bb0da52ad859a4c" + integrity sha512-mBHu/fdlzUbpGX8SZFxgbKvK/sgqLfDLP8uh8G7Us+zJgdjO8OSEeqHQs+kPRdQmdLJQiqPJX2WXgCl7ToTWqw== + dependencies: + css-select "^5.1.0" + css-tree "^1.1.3" + react-native-vector-icons@^10.0.3: version "10.0.3" resolved "https://registry.yarnpkg.com/react-native-vector-icons/-/react-native-vector-icons-10.0.3.tgz#369824a3b17994b2cd65edbaa32dbf9540d49678" diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index 601eef138..cbe17fdaa 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -44,6 +44,8 @@ export interface Spec extends TurboModule { fetchNativePackageName(): string | undefined | null; fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null; initNativeReactNavigationNewFrameTracking(): Promise; + captureReplay(isHardCrash: boolean): Promise; + getCurrentReplayId(): string | undefined | null; } export type NativeStackFrame = { diff --git a/src/js/client.ts b/src/js/client.ts index a251e2092..cc45b15e8 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -18,6 +18,8 @@ import { Alert } from 'react-native'; import { createIntegration } from './integrations/factory'; import { defaultSdkInfo } from './integrations/sdkinfo'; import type { ReactNativeClientOptions } from './options'; +import type { mobileReplayIntegration } from './replay/mobilereplay'; +import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay'; import { ReactNativeTracing } from './tracing'; import { createUserFeedbackEnvelope, items } from './utils/envelope'; import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs'; @@ -44,7 +46,6 @@ export class ReactNativeClient extends BaseClient { super(options); this._outcomesBuffer = []; - this._initNativeSdk(); } /** @@ -106,6 +107,14 @@ export class ReactNativeClient extends BaseClient { this._sendEnvelope(envelope); } + /** + * @inheritDoc + */ + public init(): void { + super.init(); + this._initNativeSdk(); + } + /** * Sets up the integrations */ @@ -160,7 +169,14 @@ export class ReactNativeClient extends BaseClient { * Starts native client with dsn and options */ private _initNativeSdk(): void { - NATIVE.initNativeSdk(this._options) + NATIVE.initNativeSdk({ + ...this._options, + mobileReplayOptions: + this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] && + 'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] + ? (this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] as ReturnType).options + : undefined, + }) .then( (result: boolean) => { return result; diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts index 37abe6ba5..e6efd9078 100644 --- a/src/js/integrations/default.ts +++ b/src/js/integrations/default.ts @@ -1,3 +1,5 @@ +/* eslint-disable complexity */ +import type { BrowserOptions } from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { ReactNativeClientOptions } from '../options'; @@ -8,6 +10,7 @@ import { browserApiErrorsIntegration, browserGlobalHandlersIntegration, browserLinkedErrorsIntegration, + browserReplayIntegration, debugSymbolicatorIntegration, dedupeIntegration, deviceContextIntegration, @@ -18,6 +21,7 @@ import { httpClientIntegration, httpContextIntegration, inboundFiltersIntegration, + mobileReplayIntegration, modulesLoaderIntegration, nativeLinkedErrorsIntegration, nativeReleaseIntegration, @@ -112,5 +116,16 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ ); } + if ( + (options._experiments && typeof options._experiments.replaysOnErrorSampleRate === 'number') || + (options._experiments && typeof options._experiments.replaysSessionSampleRate === 'number') + ) { + integrations.push(notWeb() ? mobileReplayIntegration() : browserReplayIntegration()); + if (!notWeb()) { + (options as BrowserOptions).replaysOnErrorSampleRate = options._experiments.replaysOnErrorSampleRate; + (options as BrowserOptions).replaysSessionSampleRate = options._experiments.replaysSessionSampleRate; + } + } + return integrations; } diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index b229c3cf5..1bb337d5c 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -12,6 +12,7 @@ export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; +export { mobileReplayIntegration } from '../replay/mobilereplay'; export { breadcrumbsIntegration, @@ -24,4 +25,5 @@ export { inboundFiltersIntegration, linkedErrorsIntegration as browserLinkedErrorsIntegration, rewriteFramesIntegration, + replayIntegration as browserReplayIntegration, } from '@sentry/react'; diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index 5b9a32f3d..1dac16581 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -14,3 +14,4 @@ export { Screenshot } from './screenshot'; export { ViewHierarchy } from './viewhierarchy'; export { ExpoContext } from './expocontext'; export { Spotlight } from './spotlight'; +export { mobileReplayIntegration } from '../replay/mobilereplay'; diff --git a/src/js/options.ts b/src/js/options.ts index bf44620cd..0c5a4baa4 100644 --- a/src/js/options.ts +++ b/src/js/options.ts @@ -187,6 +187,32 @@ export interface BaseReactNativeOptions { * from the function, no screenshot will be attached. */ beforeScreenshot?: (event: Event, hint: EventHint) => boolean; + + /** + * Options which are in beta, or otherwise not guaranteed to be stable. + */ + _experiments?: { + [key: string]: unknown; + + /** + * The sample rate for profiling + * 1.0 will profile all transactions and 0 will profile none. + */ + profilesSampleRate?: number; + + /** + * The sample rate for session-long replays. + * 1.0 will record all sessions and 0 will record none. + */ + replaysSessionSampleRate?: number; + + /** + * The sample rate for sessions that has had an error occur. + * This is independent of `sessionSampleRate`. + * 1.0 will record all sessions and 0 will record none. + */ + replaysOnErrorSampleRate?: number; + }; } export interface ReactNativeTransportOptions extends BrowserTransportOptions { @@ -201,10 +227,12 @@ export interface ReactNativeTransportOptions extends BrowserTransportOptions { * @see ReactNativeFrontend for more information. */ -export interface ReactNativeOptions extends Options, BaseReactNativeOptions {} +export interface ReactNativeOptions + extends Omit, '_experiments'>, + BaseReactNativeOptions {} export interface ReactNativeClientOptions - extends Omit, 'tunnel'>, + extends Omit, 'tunnel' | '_experiments'>, BaseReactNativeOptions {} export interface ReactNativeWrapperOptions { diff --git a/src/js/replay/mobilereplay.ts b/src/js/replay/mobilereplay.ts new file mode 100644 index 000000000..aa99c256c --- /dev/null +++ b/src/js/replay/mobilereplay.ts @@ -0,0 +1,145 @@ +import type { Client, DynamicSamplingContext, Event, IntegrationFnResult } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { isHardCrash } from '../misc'; +import { hasHooks } from '../utils/clientutils'; +import { isExpoGo, notMobileOs } from '../utils/environment'; +import { NATIVE } from '../wrapper'; +import { enrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; + +export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay'; + +export interface MobileReplayOptions { + /** + * Mask all text in recordings + * + * @default true + */ + maskAllText?: boolean; + + /** + * Mask all text in recordings + * + * @default true + */ + maskAllImages?: boolean; + + /** + * Mask all vector graphics in recordings + * Supports `react-native-svg` + * + * @default true + */ + maskAllVectors?: boolean; +} + +const defaultOptions: Required = { + maskAllText: true, + maskAllImages: true, + maskAllVectors: true, +}; + +type MobileReplayIntegration = IntegrationFnResult & { + options: Required; +}; + +/** + * The Mobile Replay Integration, let's you adjust the default mobile replay options. + * To be passed to `Sentry.init` with `replaysOnErrorSampleRate` or `replaysSessionSampleRate`. + * + * ```javascript + * Sentry.init({ + * _experiments: { + * replaysOnErrorSampleRate: 1.0, + * replaysSessionSampleRate: 1.0, + * }, + * integrations: [mobileReplayIntegration({ + * // Adjust the default options + * })], + * }); + * ``` + * + * @experimental + */ +export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defaultOptions): MobileReplayIntegration => { + if (isExpoGo()) { + logger.warn( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} is not supported in Expo Go. Use EAS Build or \`expo prebuild\` to enable it.`, + ); + } + if (notMobileOs()) { + logger.warn(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} is not supported on this platform.`); + } + + if (isExpoGo() || notMobileOs()) { + return mobileReplayIntegrationNoop(); + } + + const options = { ...defaultOptions, ...initOptions }; + + async function processEvent(event: Event): Promise { + const hasException = event.exception && event.exception.values && event.exception.values.length > 0; + if (!hasException) { + // Event is not an error, will not capture replay + return event; + } + + const recordingReplayId = NATIVE.getCurrentReplayId(); + if (recordingReplayId) { + logger.debug( + `[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} assign already recording replay ${recordingReplayId} for event ${event.event_id}.`, + ); + return event; + } + + const replayId = await NATIVE.captureReplay(isHardCrash(event)); + if (!replayId) { + logger.debug(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} not sampled for event ${event.event_id}.`); + return event; + } + + return event; + } + + function setup(client: Client): void { + if (!hasHooks(client)) { + return; + } + + client.on('createDsc', (dsc: DynamicSamplingContext) => { + if (dsc.replay_id) { + return; + } + + // TODO: For better performance, we should emit replayId changes on native, and hold the replayId value in JS + const currentReplayId = NATIVE.getCurrentReplayId(); + if (currentReplayId) { + dsc.replay_id = currentReplayId; + } + }); + + client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay); + } + + // TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably + // 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, + }; +}; + +const mobileReplayIntegrationNoop = (): MobileReplayIntegration => { + return { + name: MOBILE_REPLAY_INTEGRATION_NAME, + setupOnce() { + /* Noop */ + }, + options: defaultOptions, + }; +}; diff --git a/src/js/replay/networkUtils.ts b/src/js/replay/networkUtils.ts new file mode 100644 index 000000000..6834294b3 --- /dev/null +++ b/src/js/replay/networkUtils.ts @@ -0,0 +1,64 @@ +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import { utf8ToBytes } from '../vendor'; + +/** Convert a Content-Length header to number/undefined. */ +export function parseContentLengthHeader(header: string | null | undefined): number | undefined { + if (!header) { + return undefined; + } + + const size = parseInt(header, 10); + return isNaN(size) ? undefined : size; +} + +export type RequestBody = null | Blob | FormData | URLSearchParams | string | ArrayBuffer | undefined; + +/** Get the size of a body. */ +export function getBodySize(body: RequestBody): number | undefined { + if (!body) { + return undefined; + } + + try { + if (typeof body === 'string') { + return _encode(body).length; + } + + if (body instanceof URLSearchParams) { + return _encode(body.toString()).length; + } + + if (body instanceof FormData) { + const formDataStr = _serializeFormData(body); + return _encode(formDataStr).length; + } + + if (body instanceof Blob) { + return body.size; + } + + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + + // Currently unhandled types: ArrayBufferView, ReadableStream + } catch { + // just return 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' + // @ts-expect-error passing FormData to URLSearchParams won't correctly serialize `File` entries, which is fine for this use-case. See https://github.com/microsoft/TypeScript/issues/30584 + return new URLSearchParams(formData).toString(); +} diff --git a/src/js/replay/xhrUtils.ts b/src/js/replay/xhrUtils.ts new file mode 100644 index 000000000..a0ac892b9 --- /dev/null +++ b/src/js/replay/xhrUtils.ts @@ -0,0 +1,52 @@ +import type { Breadcrumb, BreadcrumbHint, SentryWrappedXMLHttpRequest, XhrBreadcrumbHint } from '@sentry/types'; +import { dropUndefinedKeys } from '@sentry/utils'; + +import type { RequestBody } from './networkUtils'; +import { getBodySize, parseContentLengthHeader } from './networkUtils'; + +/** + * Enrich an XHR breadcrumb with additional data for Mobile Replay network tab. + */ +export function enrichXhrBreadcrumbsForMobileReplay(breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined): void { + if (breadcrumb.category !== 'xhr' || !hint) { + return; + } + + const xhrHint = hint as Partial; + if (!xhrHint.xhr) { + return; + } + + const now = Date.now(); + const { startTimestamp = now, endTimestamp = now, input, xhr } = xhrHint; + + const reqSize = getBodySize(input); + const resSize = xhr.getResponseHeader('content-length') + ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) + : _getBodySize(xhr.response, xhr.responseType); + + breadcrumb.data = dropUndefinedKeys({ + start_timestamp: startTimestamp, + end_timestamp: endTimestamp, + request_body_size: reqSize, + response_body_size: resSize, + ...breadcrumb.data, + }); +} + +type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; + +function _getBodySize( + body: XMLHttpRequest['response'], + responseType: XMLHttpRequest['responseType'], +): number | undefined { + try { + const bodyStr = responseType === 'json' && body && typeof body === 'object' ? JSON.stringify(body) : body; + return getBodySize(bodyStr); + } catch { + return undefined; + } +} diff --git a/src/js/tools/enableLogger.ts b/src/js/tools/enableLogger.ts new file mode 100644 index 000000000..a5d36ade2 --- /dev/null +++ b/src/js/tools/enableLogger.ts @@ -0,0 +1,10 @@ +import { logger } from '@sentry/utils'; + +/** + * Enables debug logger when SENTRY_LOG_LEVEL=debug. + */ +export function enableLogger(): void { + if (process.env.SENTRY_LOG_LEVEL === 'debug') { + logger.enable(); + } +} diff --git a/src/js/tools/metroconfig.ts b/src/js/tools/metroconfig.ts index 8f4092201..6e5854475 100644 --- a/src/js/tools/metroconfig.ts +++ b/src/js/tools/metroconfig.ts @@ -1,24 +1,51 @@ +import { logger } from '@sentry/utils'; import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro'; +import * as process from 'process'; import { env } from 'process'; +import { enableLogger } from './enableLogger'; +import { cleanDefaultBabelTransformerPath, saveDefaultBabelTransformerPath } from './sentryBabelTransformerUtils'; import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } from './sentryMetroSerializer'; import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; export * from './sentryMetroSerializer'; +enableLogger(); + +export interface SentryMetroConfigOptions { + /** + * Annotates React components with Sentry data. + * @default false + */ + annotateReactComponents?: boolean; +} + +export interface SentryExpoConfigOptions { + /** + * Pass a custom `getDefaultConfig` function to override the default Expo configuration getter. + */ + getDefaultConfig?: typeof getSentryExpoConfig; +} + /** * Adds Sentry to the Metro config. * * Adds Debug ID to the output bundle and source maps. * Collapses Sentry frames from the stack trace view in LogBox. */ -export function withSentryConfig(config: MetroConfig): MetroConfig { +export function withSentryConfig( + config: MetroConfig, + { annotateReactComponents = false }: SentryMetroConfigOptions = {}, +): MetroConfig { setSentryMetroDevServerEnvFlag(); let newConfig = config; newConfig = withSentryDebugId(newConfig); newConfig = withSentryFramesCollapsed(newConfig); + if (annotateReactComponents) { + newConfig = withSentryBabelTransformer(newConfig); + } return newConfig; } @@ -28,7 +55,7 @@ export function withSentryConfig(config: MetroConfig): MetroConfig { */ export function getSentryExpoConfig( projectRoot: string, - options: DefaultConfigOptions & { getDefaultConfig?: typeof getSentryExpoConfig } = {}, + options: DefaultConfigOptions & SentryExpoConfigOptions & SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -41,7 +68,12 @@ export function getSentryExpoConfig( ], }); - return withSentryFramesCollapsed(config); + let newConfig = withSentryFramesCollapsed(config); + if (options.annotateReactComponents) { + newConfig = withSentryBabelTransformer(newConfig); + } + + return newConfig; } function loadExpoMetroConfigModule(): { @@ -64,6 +96,38 @@ function loadExpoMetroConfigModule(): { } } +/** + * Adds Sentry Babel transformer to the Metro config. + */ +export function withSentryBabelTransformer(config: MetroConfig): MetroConfig { + const defaultBabelTransformerPath = config.transformer && config.transformer.babelTransformerPath; + logger.debug('Default Babel transformer path from `config.transformer`:', defaultBabelTransformerPath); + + if (!defaultBabelTransformerPath) { + // This has to be console.warn because the options is enabled but won't be used + // eslint-disable-next-line no-console + console.warn('`transformer.babelTransformerPath` is undefined.'); + // eslint-disable-next-line no-console + console.warn('Sentry Babel transformer cannot be used. Not adding it...'); + return config; + } + + if (defaultBabelTransformerPath) { + saveDefaultBabelTransformerPath(defaultBabelTransformerPath); + process.on('exit', () => { + cleanDefaultBabelTransformerPath(); + }); + } + + return { + ...config, + transformer: { + ...config.transformer, + babelTransformerPath: require.resolve('./sentryBabelTransformer'), + }, + }; +} + type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; function withSentryDebugId(config: MetroConfig): MetroConfig { diff --git a/src/js/tools/sentryBabelTransformer.ts b/src/js/tools/sentryBabelTransformer.ts new file mode 100644 index 000000000..e1833fab7 --- /dev/null +++ b/src/js/tools/sentryBabelTransformer.ts @@ -0,0 +1,43 @@ +import componentAnnotatePlugin from '@sentry/babel-plugin-component-annotate'; + +import { enableLogger } from './enableLogger'; +import { loadDefaultBabelTransformer } from './sentryBabelTransformerUtils'; +import type { BabelTransformer, BabelTransformerArgs } from './vendor/metro/metroBabelTransformer'; + +enableLogger(); + +/** + * Creates a Babel transformer with Sentry component annotation plugin. + */ +function createSentryBabelTransformer(): BabelTransformer { + const defaultTransformer = loadDefaultBabelTransformer(); + + // Using spread operator to avoid any conflicts with the default transformer + const transform: BabelTransformer['transform'] = (...args) => { + const transformerArgs = args[0]; + + addSentryComponentAnnotatePlugin(transformerArgs); + + return defaultTransformer.transform(...args); + }; + + return { + ...defaultTransformer, + transform, + }; +} + +function addSentryComponentAnnotatePlugin(args: BabelTransformerArgs | undefined): void { + if (!args || typeof args.filename !== 'string' || !Array.isArray(args.plugins)) { + return undefined; + } + + if (!args.filename.includes('node_modules')) { + args.plugins.push(componentAnnotatePlugin); + } +} + +const sentryBabelTransformer = createSentryBabelTransformer(); +// With TS set to `commonjs` this will be translated to `module.exports = sentryBabelTransformer;` +// which will be correctly picked up by Metro +export = sentryBabelTransformer; diff --git a/src/js/tools/sentryBabelTransformerUtils.ts b/src/js/tools/sentryBabelTransformerUtils.ts new file mode 100644 index 000000000..dd04d2f67 --- /dev/null +++ b/src/js/tools/sentryBabelTransformerUtils.ts @@ -0,0 +1,65 @@ +import { logger } from '@sentry/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as process from 'process'; + +import type { BabelTransformer } from './vendor/metro/metroBabelTransformer'; + +/** + * Saves default Babel transformer path to the project root. + */ +export function saveDefaultBabelTransformerPath(defaultBabelTransformerPath: string): void { + try { + fs.mkdirSync(path.join(process.cwd(), '.sentry'), { recursive: true }); + fs.writeFileSync(getDefaultBabelTransformerPath(), defaultBabelTransformerPath); + logger.debug('Saved default Babel transformer path'); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[Sentry] Failed to save default Babel transformer path:', e); + } +} + +/** + * Reads default Babel transformer path from the project root. + */ +export function readDefaultBabelTransformerPath(): string | undefined { + try { + return fs.readFileSync(getDefaultBabelTransformerPath()).toString(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[Sentry] Failed to read default Babel transformer path:', e); + } + return undefined; +} + +/** + * Cleans default Babel transformer path from the project root. + */ +export function cleanDefaultBabelTransformerPath(): void { + try { + fs.unlinkSync(getDefaultBabelTransformerPath()); + logger.debug('Cleaned default Babel transformer path'); + } catch (e) { + // We don't want to fail the build if we can't clean the file + // eslint-disable-next-line no-console + console.error('[Sentry] Failed to clean default Babel transformer path:', e); + } +} + +function getDefaultBabelTransformerPath(): string { + return path.join(process.cwd(), '.sentry/.defaultBabelTransformerPath'); +} + +/** + * Loads default Babel transformer from `@react-native/metro-config` -> `@react-native/metro-babel-transformer`. + */ +export function loadDefaultBabelTransformer(): BabelTransformer { + const defaultBabelTransformerPath = readDefaultBabelTransformerPath(); + if (!defaultBabelTransformerPath) { + throw new Error('Default Babel Transformer Path not found in `.sentry` directory.'); + } + + logger.debug(`Loading default Babel transformer from ${defaultBabelTransformerPath}`); + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(defaultBabelTransformerPath); +} diff --git a/src/js/tools/vendor/metro/metroBabelTransformer.ts b/src/js/tools/vendor/metro/metroBabelTransformer.ts new file mode 100644 index 000000000..62b561694 --- /dev/null +++ b/src/js/tools/vendor/metro/metroBabelTransformer.ts @@ -0,0 +1,64 @@ +// Vendored / modified from @facebook/metro + +// https://github.com/facebook/metro/blob/9b295e5f7ecd9cb6332a199bf9cdc1bd8fddf6d9/packages/metro-babel-transformer/types/index.d.ts + +// MIT License + +// Copyright (c) Meta Platforms, Inc. and affiliates. + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export interface CustomTransformOptions { + [key: string]: unknown; +} + +export type TransformProfile = 'default' | 'hermes-stable' | 'hermes-canary'; + +export interface BabelTransformerOptions { + readonly customTransformOptions?: CustomTransformOptions; + readonly dev: boolean; + readonly enableBabelRCLookup?: boolean; + readonly enableBabelRuntime: boolean | string; + readonly extendsBabelConfigPath?: string; + readonly experimentalImportSupport?: boolean; + readonly hermesParser?: boolean; + readonly hot: boolean; + readonly minify: boolean; + readonly unstable_disableES6Transforms?: boolean; + readonly platform: string | null; + readonly projectRoot: string; + readonly publicPath: string; + readonly unstable_transformProfile?: TransformProfile; + readonly globalPrefix: string; +} + +export interface BabelTransformerArgs { + readonly filename: string; + readonly options: BabelTransformerOptions; + readonly plugins?: unknown; + readonly src: string; +} + +export interface BabelTransformer { + transform: (args: BabelTransformerArgs) => { + ast: unknown; + metadata: unknown; + }; + getCacheKey?: () => string; +} diff --git a/src/js/touchevents.tsx b/src/js/touchevents.tsx index 88ba17886..ebb2ba26c 100644 --- a/src/js/touchevents.tsx +++ b/src/js/touchevents.tsx @@ -1,6 +1,6 @@ import { addBreadcrumb, getCurrentHub } from '@sentry/core'; import type { SeverityLevel } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { dropUndefinedKeys, logger } from '@sentry/utils'; import * as React from 'react'; import type { GestureResponderEvent } from 'react-native'; import { StyleSheet, View } from 'react-native'; @@ -189,38 +189,7 @@ class TouchEventBoundary extends React.Component { break; } - const props = currentInst.memoizedProps ?? {}; - const info: TouchedComponentInfo = {}; - - // provided by @sentry/babel-plugin-component-annotate - if (typeof props[SENTRY_COMPONENT_PROP_KEY] === 'string' && props[SENTRY_COMPONENT_PROP_KEY].length > 0 && props[SENTRY_COMPONENT_PROP_KEY] !== 'unknown') { - info.name = props[SENTRY_COMPONENT_PROP_KEY]; - } - if (typeof props[SENTRY_ELEMENT_PROP_KEY] === 'string' && props[SENTRY_ELEMENT_PROP_KEY].length > 0 && props[SENTRY_ELEMENT_PROP_KEY] !== 'unknown') { - info.element = props[SENTRY_ELEMENT_PROP_KEY]; - } - if (typeof props[SENTRY_FILE_PROP_KEY] === 'string' && props[SENTRY_FILE_PROP_KEY].length > 0 && props[SENTRY_FILE_PROP_KEY] !== 'unknown') { - info.file = props[SENTRY_FILE_PROP_KEY]; - } - - // use custom label if provided by the user, or displayName if available - const labelValue = - typeof props[SENTRY_LABEL_PROP_KEY] === 'string' - ? props[SENTRY_LABEL_PROP_KEY] - : // For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in - // the "check-label" if sentence, so we have to assign it to a variable here first - typeof this.props.labelName === 'string' - ? props[this.props.labelName] - : undefined; - - if (typeof labelValue === 'string' && labelValue.length > 0) { - info.label = labelValue; - } - - if (!info.name && currentInst.elementType?.displayName) { - info.name = currentInst.elementType?.displayName; - } - + const info = getTouchedComponentInfo(currentInst, this.props.labelName); this._pushIfNotIgnored(touchPath, info); currentInst = currentInst.return; @@ -240,7 +209,11 @@ class TouchEventBoundary extends React.Component { /** * Pushes the name to the componentTreeNames array if it is not ignored. */ - private _pushIfNotIgnored(touchPath: TouchedComponentInfo[], value: TouchedComponentInfo): boolean { + private _pushIfNotIgnored(touchPath: TouchedComponentInfo[], value: TouchedComponentInfo | undefined): boolean { + if (!value) { + return false; + } + if (!value.name && !value.label) { return false; } @@ -261,6 +234,62 @@ class TouchEventBoundary extends React.Component { } } +function getTouchedComponentInfo(currentInst: ElementInstance, labelKey: string | undefined): TouchedComponentInfo | undefined { + const displayName = currentInst.elementType?.displayName; + + const props = currentInst.memoizedProps; + if (!props) { + // Early return if no props are available, as we can't extract any useful information + if (displayName) { + return { + name: displayName, + }; + } + return undefined; + } + + return dropUndefinedKeys({ + // provided by @sentry/babel-plugin-component-annotate + name: getComponentName(props) || displayName, + element: getElementName(props), + file: getFileName(props), + + // `sentry-label` or user defined label key + label: getLabelValue(props, labelKey), + }); +} + +function getComponentName(props: Record): string | undefined { + return typeof props[SENTRY_COMPONENT_PROP_KEY] === 'string' && + props[SENTRY_COMPONENT_PROP_KEY].length > 0 && + props[SENTRY_COMPONENT_PROP_KEY] !== 'unknown' && + props[SENTRY_COMPONENT_PROP_KEY] || undefined; +} + +function getElementName(props: Record): string | undefined { + return typeof props[SENTRY_ELEMENT_PROP_KEY] === 'string' && + props[SENTRY_ELEMENT_PROP_KEY].length > 0 && + props[SENTRY_ELEMENT_PROP_KEY] !== 'unknown' && + props[SENTRY_ELEMENT_PROP_KEY] || undefined; +} + +function getFileName(props: Record): string | undefined { + return typeof props[SENTRY_FILE_PROP_KEY] === 'string' && + props[SENTRY_FILE_PROP_KEY].length > 0 && + props[SENTRY_FILE_PROP_KEY] !== 'unknown' && + props[SENTRY_FILE_PROP_KEY] || undefined; +} + +function getLabelValue(props: Record, labelKey: string | undefined): string | undefined { + return typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0 + ? props[SENTRY_LABEL_PROP_KEY] as string + // For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in + // the "check-label" if sentence, so we have to assign it to a variable here first + : typeof labelKey === 'string' && typeof props[labelKey] == 'string' && (props[labelKey] as string).length > 0 + ? props[labelKey] as string + : undefined; +} + /** * Convenience Higher-Order-Component for TouchEventBoundary * @param WrappedComponent any React Component diff --git a/src/js/utils/clientutils.ts b/src/js/utils/clientutils.ts new file mode 100644 index 000000000..95047fa00 --- /dev/null +++ b/src/js/utils/clientutils.ts @@ -0,0 +1,10 @@ +import type { Client } from '@sentry/types'; + +/** + * Checks if the provided Sentry client has hooks implemented. + * @param client The Sentry client object to check. + * @returns True if the client has hooks, false otherwise. + */ +export function hasHooks(client: Client): client is Client & { on: Required['on'] } { + return client.on !== undefined; +} diff --git a/src/js/utils/environment.ts b/src/js/utils/environment.ts index 9e2c96c13..19b120a56 100644 --- a/src/js/utils/environment.ts +++ b/src/js/utils/environment.ts @@ -58,6 +58,16 @@ export function notWeb(): boolean { return Platform.OS !== 'web'; } +/** Checks if the current platform is supported mobile platform (iOS or Android) */ +export function isMobileOs(): boolean { + return Platform.OS === 'ios' || Platform.OS === 'android'; +} + +/** Checks if the current platform is not supported mobile platform (iOS or Android) */ +export function notMobileOs(): boolean { + return !isMobileOs(); +} + /** Returns Hermes Version if hermes is present in the runtime */ export function getHermesVersion(): string | undefined { return ( diff --git a/src/js/utils/worldwide.ts b/src/js/utils/worldwide.ts index 4f1cfc4c7..721561367 100644 --- a/src/js/utils/worldwide.ts +++ b/src/js/utils/worldwide.ts @@ -24,7 +24,13 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { }; __BUNDLE_START_TIME__?: number; nativePerformanceNow?: () => number; + TextEncoder?: TextEncoder; } +type TextEncoder = { + new (): TextEncoder; + encode(input?: string): Uint8Array; +}; + /** Get's the global object for the current JavaScript runtime */ export const RN_GLOBAL_OBJ = GLOBAL_OBJ as ReactNativeInternalGlobal; diff --git a/src/js/version.ts b/src/js/version.ts index b5e2dc012..1d5c03fa8 100644 --- a/src/js/version.ts +++ b/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 = '5.25.0'; +export const SDK_VERSION = '5.26.0-alpha.3'; diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index ca272e616..2e16f8902 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -25,6 +25,7 @@ import type { import type { ReactNativeClientOptions } from './options'; 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 { isTurboModuleEnabled } from './utils/environment'; import { ReactNativeLibraries } from './utils/rnlibraries'; @@ -47,6 +48,10 @@ export interface Screenshot { filename: string; } +export type NativeSdkOptions = Partial & { + mobileReplayOptions: MobileReplayOptions | undefined; +}; + interface SentryNativeWrapper { enableNative: boolean; nativeIsReady: boolean; @@ -63,7 +68,7 @@ interface SentryNativeWrapper { isNativeAvailable(): boolean; - initNativeSdk(options: Partial): PromiseLike; + initNativeSdk(options: NativeSdkOptions): PromiseLike; closeNativeSdk(): PromiseLike; sendEnvelope(envelope: Envelope): Promise; @@ -104,6 +109,9 @@ interface SentryNativeWrapper { */ fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | null; initNativeReactNavigationNewFrameTracking(): Promise; + + captureReplay(isHardCrash: boolean): Promise; + getCurrentReplayId(): string | null; } const EOL = utf8ToBytes('\n'); @@ -193,8 +201,8 @@ export const NATIVE: SentryNativeWrapper = { * Starts native with the provided options. * @param options ReactNativeClientOptions */ - async initNativeSdk(originalOptions: Partial): Promise { - const options: Partial = { + async initNativeSdk(originalOptions: NativeSdkOptions): Promise { + const options: NativeSdkOptions = { enableNative: true, autoInitializeNativeSdk: true, ...originalOptions, @@ -608,6 +616,32 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.initNativeReactNavigationNewFrameTracking(); }, + async captureReplay(isHardCrash: boolean): Promise { + if (!this.enableNative) { + logger.warn(`[NATIVE] \`${this.captureReplay.name}\` is not available when native is disabled.`); + return Promise.resolve(null); + } + if (!this._isModuleLoaded(RNSentry)) { + logger.warn(`[NATIVE] \`${this.captureReplay.name}\` is not available when native is not available.`); + return Promise.resolve(null); + } + + return (await RNSentry.captureReplay(isHardCrash)) || null; + }, + + getCurrentReplayId(): string | null { + if (!this.enableNative) { + logger.warn(`[NATIVE] \`${this.getCurrentReplayId.name}\` is not available when native is disabled.`); + return null; + } + if (!this._isModuleLoaded(RNSentry)) { + logger.warn(`[NATIVE] \`${this.getCurrentReplayId.name}\` is not available when native is not available.`); + return null; + } + + return RNSentry.getCurrentReplayId() || null; + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/test/client.test.ts b/test/client.test.ts index 562009d83..2cd761f78 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -241,7 +241,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); test('catches errors from onReady callback', () => { @@ -254,7 +254,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); test('calls onReady callback with false if Native SDK was not initialized', done => { @@ -269,7 +269,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); test('calls onReady callback with false if Native SDK failed to initialize', done => { @@ -290,7 +290,7 @@ describe('Tests ReactNativeClient', () => { }, transport: () => new NativeTransport(), }), - ); + ).init(); }); }); diff --git a/test/react-native/rn.patch.metro.config.js b/test/react-native/rn.patch.metro.config.js index 05cb2b4fe..f4354c215 100755 --- a/test/react-native/rn.patch.metro.config.js +++ b/test/react-native/rn.patch.metro.config.js @@ -20,6 +20,8 @@ const importSerializer = "const { withSentryConfig } = require('@sentry/react-na let config = fs.readFileSync(configFilePath, 'utf8').split('\n'); +const sentryOptions = '{ annotateReactComponents: true }'; + const isPatched = config.includes(importSerializer); if (!isPatched) { config = [importSerializer, ...config]; @@ -35,11 +37,18 @@ if (!isPatched) { lineParsed[1] = lineParsed[1].slice(0, -1); } - lineParsed[1] = `= withSentryConfig(${lineParsed[1]}${endsWithSemicolon ? ');' : ''}`; + lineParsed[1] = `= withSentryConfig(${lineParsed[1]}${endsWithSemicolon ? `, ${sentryOptions});` : ''}`; config[moduleExportsLineIndex] = lineParsed.join(''); if (endOfModuleExportsIndex !== -1) { - config[endOfModuleExportsIndex] = '});'; + config[endOfModuleExportsIndex] = `}, ${sentryOptions});`; + } + + // RN Before 0.72 does not include default config in the metro.config.js + // We have to specify babelTransformerPath manually + const transformerIndex = config.findIndex(line => line.includes('transformer: {')); + if (transformerIndex !== -1) { + config[transformerIndex] = `transformer: { babelTransformerPath: require.resolve('metro-babel-transformer'),`; } fs.writeFileSync(configFilePath, config.join('\n'), 'utf8'); diff --git a/test/replay/networkUtils.test.ts b/test/replay/networkUtils.test.ts new file mode 100644 index 000000000..9bacfc9ce --- /dev/null +++ b/test/replay/networkUtils.test.ts @@ -0,0 +1,59 @@ +import { getBodySize, parseContentLengthHeader } from '../../src/js/replay/networkUtils'; + +describe('networkUtils', () => { + describe('parseContentLengthHeader()', () => { + it.each([ + [undefined, undefined], + [null, undefined], + ['', undefined], + ['12', 12], + ['abc', undefined], + ])('works with %s header value', (headerValue, size) => { + expect(parseContentLengthHeader(headerValue)).toBe(size); + }); + }); + + describe('getBodySize()', () => { + it('works with empty body', () => { + expect(getBodySize(undefined)).toBe(undefined); + expect(getBodySize(null)).toBe(undefined); + expect(getBodySize('')).toBe(undefined); + }); + + it('works with string body', () => { + expect(getBodySize('abcd')).toBe(4); + // Emojis are correctly counted as mutliple characters + expect(getBodySize('With emoji: 😈')).toBe(16); + }); + + it('works with URLSearchParams', () => { + const params = new URLSearchParams(); + params.append('name', 'Jane'); + params.append('age', '42'); + params.append('emoji', '😈'); + + expect(getBodySize(params)).toBe(35); + }); + + it('works with FormData', () => { + const formData = new FormData(); + formData.append('name', 'Jane'); + formData.append('age', '42'); + formData.append('emoji', '😈'); + + expect(getBodySize(formData)).toBe(35); + }); + + it('works with Blob', () => { + const blob = new Blob(['Hello world: 😈'], { type: 'text/html', lastModified: 0 }); + + expect(getBodySize(blob)).toBe(30); + }); + + it('works with ArrayBuffer', () => { + const arrayBuffer = new ArrayBuffer(8); + + expect(getBodySize(arrayBuffer)).toBe(8); + }); + }); +}); diff --git a/test/replay/xhrUtils.test.ts b/test/replay/xhrUtils.test.ts new file mode 100644 index 000000000..614dae4be --- /dev/null +++ b/test/replay/xhrUtils.test.ts @@ -0,0 +1,89 @@ +import type { Breadcrumb } from '@sentry/types'; + +import { enrichXhrBreadcrumbsForMobileReplay } from '../../src/js/replay/xhrUtils'; + +describe('xhrUtils', () => { + describe('enrichXhrBreadcrumbsForMobileReplay', () => { + it('only changes xhr category breadcrumbs', () => { + const breadcrumb: Breadcrumb = { category: 'http' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb).toEqual({ category: 'http' }); + }); + + it('does nothing without hint', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, undefined); + expect(breadcrumb).toEqual({ category: 'xhr' }); + }); + + it('does nothing without xhr hint', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, {}); + expect(breadcrumb).toEqual({ category: 'xhr' }); + }); + + it('set start and end timestamp', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + start_timestamp: 1, + end_timestamp: 2, + }), + ); + }); + + it('uses now as default timestamp', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, { + ...getValidXhrHint(), + startTimestamp: undefined, + endTimestamp: undefined, + }); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + }), + ); + }); + + it('sets request body size', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + request_body_size: 10, + }), + ); + }); + + it('sets response body size', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + response_body_size: 13, + }), + ); + }); + }); +}); + +function getValidXhrHint() { + return { + startTimestamp: 1, + endTimestamp: 2, + input: 'test-input', // 10 bytes + xhr: { + getResponseHeader: (key: string) => { + if (key === 'content-length') { + return '13'; + } + throw new Error('Invalid key'); + }, + response: 'test-response', // 13 bytes + responseType: 'json', + }, + }; +} diff --git a/test/tools/fixtures/mockBabelTransformer.js b/test/tools/fixtures/mockBabelTransformer.js new file mode 100644 index 000000000..17628495a --- /dev/null +++ b/test/tools/fixtures/mockBabelTransformer.js @@ -0,0 +1,4 @@ +module.exports = { + transform: jest.fn(), + getCacheKey: jest.fn(), +}; diff --git a/test/tools/metroconfig.test.ts b/test/tools/metroconfig.test.ts index 63312c881..a0ee9533f 100644 --- a/test/tools/metroconfig.test.ts +++ b/test/tools/metroconfig.test.ts @@ -1,33 +1,112 @@ +jest.mock('fs', () => { + return { + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), + unlinkSync: jest.fn(), + }; +}); + +import * as fs from 'fs'; import type { MetroConfig } from 'metro'; +import * as path from 'path'; +import * as process from 'process'; -import { withSentryFramesCollapsed } from '../../src/js/tools/metroconfig'; +import { withSentryBabelTransformer, withSentryFramesCollapsed } from '../../src/js/tools/metroconfig'; type MetroFrame = Parameters['symbolicator']>['customizeFrame']>[0]; -describe('withSentryFramesCollapsed', () => { - test('adds customizeFrames if undefined ', () => { - const config = withSentryFramesCollapsed({}); - expect(config.symbolicator?.customizeFrame).toBeDefined(); +describe('metroconfig', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - test('wraps existing customizeFrames', async () => { - const originalCustomizeFrame = jest.fn(); - const config = withSentryFramesCollapsed({ symbolicator: { customizeFrame: originalCustomizeFrame } }); + describe('withSentryFramesCollapsed', () => { + test('adds customizeFrames if undefined ', () => { + const config = withSentryFramesCollapsed({}); + expect(config.symbolicator?.customizeFrame).toBeDefined(); + }); + + test('wraps existing customizeFrames', async () => { + const originalCustomizeFrame = jest.fn(); + const config = withSentryFramesCollapsed({ symbolicator: { customizeFrame: originalCustomizeFrame } }); + + const customizeFrame = config.symbolicator?.customizeFrame; + await customizeFrame?.(createMockSentryInstrumentMetroFrame()); - const customizeFrame = config.symbolicator?.customizeFrame; - await customizeFrame?.(createMockSentryInstrumentMetroFrame()); + expect(config.symbolicator?.customizeFrame).not.toBe(originalCustomizeFrame); + expect(originalCustomizeFrame).toHaveBeenCalledTimes(1); + }); - expect(config.symbolicator?.customizeFrame).not.toBe(originalCustomizeFrame); - expect(originalCustomizeFrame).toHaveBeenCalledTimes(1); + test('collapses sentry instrument frames', async () => { + const config = withSentryFramesCollapsed({}); + + const customizeFrame = config.symbolicator?.customizeFrame; + const customizedFrame = await customizeFrame?.(createMockSentryInstrumentMetroFrame()); + + expect(customizedFrame?.collapse).toBe(true); + }); }); - test('collapses sentry instrument frames', async () => { - const config = withSentryFramesCollapsed({}); + describe('withSentryBabelTransformer', () => { + test.each([[{}], [{ transformer: {} }], [{ transformer: { hermesParser: true } }]])( + "does not add babel transformer none is set in the config object '%o'", + input => { + expect(withSentryBabelTransformer(JSON.parse(JSON.stringify(input)))).toEqual(input); + }, + ); + + test.each([ + [{ transformer: { babelTransformerPath: 'babelTransformerPath' }, projectRoot: 'project/root' }], + [{ transformer: { babelTransformerPath: 'babelTransformerPath' } }], + ])('save default babel transformer path to a file', () => { + const defaultBabelTransformerPath = '/default/babel/transformer'; + + withSentryBabelTransformer({ + transformer: { + babelTransformerPath: defaultBabelTransformerPath, + }, + projectRoot: 'project/root', + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith(path.join(process.cwd(), '.sentry'), { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(process.cwd(), '.sentry/.defaultBabelTransformerPath'), + defaultBabelTransformerPath, + ); + }); + + test('clean default babel transformer path file on exit', () => { + const processOnSpy: jest.SpyInstance = jest.spyOn(process, 'on'); + + const defaultBabelTransformerPath = 'defaultBabelTransformerPath'; + + withSentryBabelTransformer({ + transformer: { + babelTransformerPath: defaultBabelTransformerPath, + }, + projectRoot: 'project/root', + }); + + const actualExitHandler: () => void | undefined = processOnSpy.mock.calls[0][1]; + actualExitHandler?.(); + + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(fs.unlinkSync).toHaveBeenCalledWith(path.join(process.cwd(), '.sentry/.defaultBabelTransformerPath')); + }); + + test('return config with sentry babel transformer path', () => { + const defaultBabelTransformerPath = 'defaultBabelTransformerPath'; - const customizeFrame = config.symbolicator?.customizeFrame; - const customizedFrame = await customizeFrame?.(createMockSentryInstrumentMetroFrame()); + const config = withSentryBabelTransformer({ + transformer: { + babelTransformerPath: defaultBabelTransformerPath, + }, + }); - expect(customizedFrame?.collapse).toBe(true); + expect(config.transformer?.babelTransformerPath).toBe( + require.resolve('../../src/js/tools/sentryBabelTransformer'), + ); + }); }); }); diff --git a/test/tools/sentryBabelTransformer.test.ts b/test/tools/sentryBabelTransformer.test.ts new file mode 100644 index 000000000..3c888d119 --- /dev/null +++ b/test/tools/sentryBabelTransformer.test.ts @@ -0,0 +1,87 @@ +jest.mock('fs', () => { + return { + readFileSync: jest.fn(), + }; +}); + +import * as fs from 'fs'; + +// needs to be defined before sentryBabelTransformer is imported +// the transformer is created on import (side effect) +(fs.readFileSync as jest.Mock).mockReturnValue(require.resolve('./fixtures/mockBabelTransformer.js')); + +import * as SentryBabelTransformer from '../../src/js/tools/sentryBabelTransformer'; +import type { BabelTransformerArgs } from '../../src/js/tools/vendor/metro/metroBabelTransformer'; + +const MockDefaultBabelTransformer: { + transform: jest.Mock; + getCacheKey: jest.Mock; +} = require('./fixtures/mockBabelTransformer'); + +describe('SentryBabelTransformer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('getCacheKey calls the original transformer', () => { + SentryBabelTransformer.getCacheKey?.(); + + expect(SentryBabelTransformer.getCacheKey).toBeDefined(); + expect(MockDefaultBabelTransformer.getCacheKey).toHaveBeenCalledTimes(1); + }); + + test('transform calls the original transformer with the annotation plugin', () => { + SentryBabelTransformer.transform?.({ + filename: '/project/file', + options: { + projectRoot: 'project/root', + }, + plugins: [jest.fn()], + } as BabelTransformerArgs); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith({ + filename: '/project/file', + options: { + projectRoot: 'project/root', + }, + plugins: [expect.any(Function), expect.any(Function)], + }); + expect(MockDefaultBabelTransformer.transform.mock.calls[0][0]['plugins'][1].name).toEqual( + 'componentNameAnnotatePlugin', + ); + }); + + test('transform adds plugin', () => { + SentryBabelTransformer.transform?.({ + filename: '/project/file', + options: { + projectRoot: 'project/root', + }, + plugins: [], + } as BabelTransformerArgs); + }); + + test.each([ + [ + { + filename: 'node_modules/file', + plugins: [jest.fn()], + } as BabelTransformerArgs, + ], + [ + { + filename: 'project/node_modules/file', + plugins: [jest.fn()], + } as BabelTransformerArgs, + ], + ])('transform does not add plugin if filename includes node_modules', input => { + SentryBabelTransformer.transform?.(input); + + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledTimes(1); + expect(MockDefaultBabelTransformer.transform).toHaveBeenCalledWith({ + filename: input.filename, + plugins: expect.not.arrayContaining([expect.objectContaining({ name: 'componentNameAnnotatePlugin' })]), + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index d36f2dd66..92775e219 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3804,6 +3804,11 @@ resolved "https://registry.yarnpkg.com/@sentry-internal/typescript/-/typescript-7.117.0.tgz#bd43fc07a222e98861e6ab8a85ddd60e7399cd47" integrity sha512-SylReCEo1FiTuir6XiZuV+sWBOBERDL0C3YmdHhczOh0aeu50FUja7uJfoXMx0LTEwaUAXq62dWUvb9WetluOQ== +"@sentry/babel-plugin-component-annotate@2.20.1": + version "2.20.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.20.1.tgz#204c63ed006a048f48f633876e1b8bacf87a9722" + integrity sha512-4mhEwYTK00bIb5Y9UWIELVUfru587Vaeg0DQGswv4aIRHIiMKLyNqCEejaaybQ/fNChIZOKmvyqXk430YVd7Qg== + "@sentry/browser@7.117.0": version "7.117.0" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.117.0.tgz#3030073f360974dadcf5a5f2e1542497b3be2482"