From 00e9040a2dd01f7b8f89855842368f8f8d7c260f Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:22:22 +0100 Subject: [PATCH 01/21] ref(ios): Extract Cocoa SDK init into standalone file (#4442) --- CHANGELOG.md | 1 + .../project.pbxproj | 4 + .../RNSentryStart+Test.h | 8 + .../RNSentryCocoaTesterTests/RNSentryTests.mm | 93 ++++----- packages/core/ios/RNSentry.h | 5 - packages/core/ios/RNSentry.mm | 171 +--------------- packages/core/ios/RNSentryStart.h | 20 ++ packages/core/ios/RNSentryStart.m | 192 ++++++++++++++++++ 8 files changed, 267 insertions(+), 227 deletions(-) create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h create mode 100644 packages/core/ios/RNSentryStart.h create mode 100644 packages/core/ios/RNSentryStart.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5ce40fb2..0bcb7ef63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Internal - Initialize `RNSentryTimeToDisplay` during native module `init` on iOS ([#4443](https://github.com/getsentry/sentry-react-native/pull/4443)) +- Extract iOS native initialization to standalone structures ([#4442](https://github.com/getsentry/sentry-react-native/pull/4442)) ### Dependencies diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index f78b1be0e0..112c485d6f 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 332D33482CDBDC7300547D76 /* RNSentry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentry.h; path = ../ios/RNSentry.h; sourceTree = SOURCE_ROOT; }; 332D33492CDCC8E100547D76 /* RNSentryTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryTests.h; sourceTree = ""; }; 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; + 333B58A82D35BA93000F8D04 /* RNSentryStart.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryStart.h; path = ../ios/RNSentryStart.h; sourceTree = SOURCE_ROOT; }; + 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "RNSentryStart+Test.h"; path = "RNSentryCocoaTesterTests/RNSentryStart+Test.h"; sourceTree = SOURCE_ROOT; }; 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 = ""; }; @@ -116,6 +118,8 @@ 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */, + 333B58A82D35BA93000F8D04 /* RNSentryStart.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h new file mode 100644 index 0000000000..fcdfe7872b --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h @@ -0,0 +1,8 @@ +#import "RNSentryStart.h" + +@interface +RNSentryStart (Test) + ++ (void)setEventOriginTag:(SentryEvent *)event; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm index 6e63793b85..9cefc4747a 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm @@ -1,4 +1,5 @@ #import "RNSentryTests.h" +#import "RNSentryStart+Test.h" #import #import #import @@ -13,7 +14,6 @@ @implementation RNSentryInitNativeSdkTests - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @@ -25,8 +25,8 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties , @"enableTracing" : @YES, } ; -SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; +SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); @@ -40,14 +40,13 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties - (void)testCaptureFailedRequestsIsDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); @@ -56,14 +55,13 @@ - (void)testCaptureFailedRequestsIsDisabled - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDefault { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], true, @@ -72,14 +70,13 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDefault - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDefault { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual( @@ -88,15 +85,14 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDefault - (void)testCreateOptionsWithDictionaryNativeCrashHandlingEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableNativeCrashHandling" : @YES, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], true, @@ -105,15 +101,14 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingEnabled - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableAutoPerformanceTracing" : @YES, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual( @@ -122,15 +117,14 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingEnabled - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableNativeCrashHandling" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], false, @@ -139,15 +133,14 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDisabled - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableAutoPerformanceTracing" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual(actualOptions.enableAutoPerformanceTracing, false, @@ -156,7 +149,6 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled - (void)testCreateOptionsWithDictionarySpotlightEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @@ -164,8 +156,8 @@ - (void)testCreateOptionsWithDictionarySpotlightEnabled @"spotlight" : @YES, @"defaultSidecarUrl" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertTrue(actualOptions.enableSpotlight, @"Did not enable spotlight"); @@ -174,7 +166,6 @@ - (void)testCreateOptionsWithDictionarySpotlightEnabled - (void)testCreateOptionsWithDictionarySpotlightOne { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @@ -182,8 +173,8 @@ - (void)testCreateOptionsWithDictionarySpotlightOne @"spotlight" : @1, @"defaultSidecarUrl" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertTrue(actualOptions.enableSpotlight, @"Did not enable spotlight"); @@ -192,15 +183,14 @@ - (void)testCreateOptionsWithDictionarySpotlightOne - (void)testCreateOptionsWithDictionarySpotlightUrl { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"spotlight" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertTrue(actualOptions.enableSpotlight, @"Did not enable spotlight"); @@ -209,15 +199,14 @@ - (void)testCreateOptionsWithDictionarySpotlightUrl - (void)testCreateOptionsWithDictionarySpotlightDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"spotlight" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertFalse(actualOptions.enableSpotlight, @"Did not disable spotlight"); @@ -225,15 +214,14 @@ - (void)testCreateOptionsWithDictionarySpotlightDisabled - (void)testCreateOptionsWithDictionarySpotlightZero { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"spotlight" : @0, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertFalse(actualOptions.enableSpotlight, @"Did not disable spotlight"); @@ -241,14 +229,13 @@ - (void)testCreateOptionsWithDictionarySpotlightZero - (void)testPassesErrorOnWrongDsn { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"not_a_valid_dsn", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNil(actualOptions, @"Created invalid sentry options"); XCTAssertNotNil(error, @"Did not created error on invalid dsn"); @@ -256,14 +243,14 @@ - (void)testPassesErrorOnWrongDsn - (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : @"http://localhost:8081" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -276,14 +263,14 @@ - (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs - (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSString *mockDevServer = @"http://localhost:8081"; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : mockDevServer }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -296,14 +283,14 @@ - (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs - (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : @"http://localhost:8081" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -316,13 +303,13 @@ - (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBr - (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedAndDsnDoesNotMatch { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ // dsn is always validated in SentryOptions initialization @"dsn" : @"https://abc@def.ingest.sentry.io/1234567" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -335,13 +322,12 @@ - (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedA - (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags { - RNSentry *rnSentry = [[RNSentry alloc] init]; SentryEvent *testEvent = [[SentryEvent alloc] init]; testEvent.sdk = @{ @"name" : @"sentry.cocoa.react-native", }; - [rnSentry setEventOriginTag:testEvent]; + [RNSentryStart setEventOriginTag:testEvent]; XCTAssertEqual(testEvent.tags[@"event.origin"], @"ios"); XCTAssertEqual(testEvent.tags[@"event.environment"], @"native"); @@ -349,7 +335,6 @@ - (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags - (void)testEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritten { - RNSentry *rnSentry = [[RNSentry alloc] init]; SentryEvent *testEvent = [[SentryEvent alloc] init]; testEvent.sdk = @{ @"name" : @"sentry.cocoa.react-native", @@ -359,7 +344,7 @@ - (void)testEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritten @"event.environment" : @"testEventEnvironmentTag", }; - [rnSentry setEventOriginTag:testEvent]; + [RNSentryStart setEventOriginTag:testEvent]; XCTAssertEqual(testEvent.tags[@"event.origin"], @"ios"); XCTAssertEqual(testEvent.tags[@"event.environment"], @"native"); diff --git a/packages/core/ios/RNSentry.h b/packages/core/ios/RNSentry.h index cfd0b74b28..66dc7219ac 100644 --- a/packages/core/ios/RNSentry.h +++ b/packages/core/ios/RNSentry.h @@ -20,11 +20,6 @@ SentrySDK (Private) @interface RNSentry : RCTEventEmitter -- (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options - error:(NSError *_Nullable *_Nonnull)errorPointer; - -- (void)setEventOriginTag:(SentryEvent *)event; - - (NSDictionary *_Nonnull)fetchNativeStackFramesBy:(NSArray *)instructionsAddr symbolicate:(SymbolicateCallbackType)symbolicate; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 79ff76d0ae..6907513da5 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -49,6 +49,7 @@ # import "RNSentryRNSScreen.h" #endif +#import "RNSentryStart.h" #import "RNSentryVersion.h" @interface @@ -63,7 +64,6 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; static bool hasFetchedAppStart; @implementation RNSentry { - bool sentHybridSdkDidBecomeActive; bool hasListeners; RNSentryTimeToDisplay *_timeToDisplay; } @@ -94,181 +94,16 @@ - (instancetype)init : (RCTPromiseRejectBlock)reject) { NSError *error = nil; - SentryOptions *sentryOptions = [self createOptionsWithDictionary:options error:&error]; + SentryOptions *sentryOptions = [RNSentryStart createOptionsWithDictionary:options error:&error]; if (error != nil) { reject(@"SentryReactNative", error.localizedDescription, error); return; } - NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; - [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; - [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME - version:REACT_NATIVE_SDK_PACKAGE_VERSION]; - - [SentrySDK startWithOptions:sentryOptions]; - -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - BOOL appIsActive = - [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; -#else - BOOL appIsActive = [[NSApplication sharedApplication] isActive]; -#endif - - // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive - // notification, send it. - if (appIsActive && !sentHybridSdkDidBecomeActive - && (PrivateSentrySDKOnly.options.enableAutoSessionTracking - || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { - [[NSNotificationCenter defaultCenter] postNotificationName:@"SentryHybridSdkDidBecomeActive" - object:nil]; - - sentHybridSdkDidBecomeActive = true; - } - -#if SENTRY_TARGET_REPLAY_SUPPORTED - [RNSentryReplay postInit]; -#endif - + [RNSentryStart startWithOptions:sentryOptions]; resolve(@YES); } -- (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options - error:(NSError *_Nonnull *_Nonnull)errorPointer -{ - SentryBeforeSendEventCallback beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception of - // react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - return nil; - } - - [self setEventOriginTag:event]; - - return event; - }; - - NSMutableDictionary *mutableOptions = [options mutableCopy]; - [mutableOptions setValue:beforeSend forKey:@"beforeSend"]; - - // remove performance traces sample rate and traces sampler since we don't want to synchronize - // these configurations to the Native SDKs. The user could tho initialize the SDK manually and - // set themselves. - [mutableOptions removeObjectForKey:@"tracesSampleRate"]; - [mutableOptions removeObjectForKey:@"tracesSampler"]; - [mutableOptions removeObjectForKey:@"enableTracing"]; - -#if SENTRY_TARGET_REPLAY_SUPPORTED - [RNSentryReplay updateOptions:mutableOptions]; -#endif - - SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions - didFailWithError:errorPointer]; - if (*errorPointer != nil) { - return nil; - } - - // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; - NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; - sentryOptions.beforeBreadcrumb - = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) - { - NSString *url = breadcrumb.data[@"url"] ?: @""; - - if ([@"http" isEqualToString:breadcrumb.type] - && ((dsn != nil && [url hasPrefix:dsn]) - || (devServerUrl != nil && [url hasPrefix:devServerUrl]))) { - return nil; - } - return breadcrumb; - }; - - if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) { - BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue]; - - if (!enableNativeCrashHandling) { - NSMutableArray *integrations = sentryOptions.integrations.mutableCopy; - [integrations removeObject:@"SentryCrashIntegration"]; - sentryOptions.integrations = integrations; - } - } - - // Set spotlight option - if ([mutableOptions valueForKey:@"spotlight"] != nil) { - id spotlightValue = [mutableOptions valueForKey:@"spotlight"]; - if ([spotlightValue isKindOfClass:[NSString class]]) { - NSLog(@"Using Spotlight on address: %@", spotlightValue); - sentryOptions.enableSpotlight = true; - sentryOptions.spotlightUrl = spotlightValue; - } else if ([spotlightValue isKindOfClass:[NSNumber class]]) { - sentryOptions.enableSpotlight = [spotlightValue boolValue]; - id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"]; - if (defaultSpotlightUrl != nil) { - sentryOptions.spotlightUrl = defaultSpotlightUrl; - } - } - } - - // Enable the App start and Frames tracking measurements - if ([mutableOptions valueForKey:@"enableAutoPerformanceTracing"] != nil) { - BOOL enableAutoPerformanceTracing = - [mutableOptions[@"enableAutoPerformanceTracing"] boolValue]; - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#endif - } - - // Failed requests can only be enabled in one SDK to avoid duplicates - sentryOptions.enableCaptureFailedRequests = NO; - - return sentryOptions; -} - -- (NSString *_Nullable)getURLFromDSN:(NSString *)dsn -{ - NSURL *url = [NSURL URLWithString:dsn]; - if (!url) { - return nil; - } - return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; -} - -- (void)setEventOriginTag:(SentryEvent *)event -{ - if (event.sdk != nil) { - NSString *sdkName = event.sdk[@"name"]; - - // If the event is from react native, it gets set - // there and we do not handle it here. - if ([sdkName isEqual:NATIVE_SDK_NAME]) { - [self setEventEnvironmentTag:event origin:@"ios" environment:@"native"]; - } - } -} - -- (void)setEventEnvironmentTag:(SentryEvent *)event - origin:(NSString *)origin - environment:(NSString *)environment -{ - NSMutableDictionary *newTags = [NSMutableDictionary new]; - - if (nil != event.tags && [event.tags count] > 0) { - [newTags addEntriesFromDictionary:event.tags]; - } - if (nil != origin) { - [newTags setValue:origin forKey:@"event.origin"]; - } - if (nil != environment) { - [newTags setValue:environment forKey:@"event.environment"]; - } - - event.tags = newTags; -} - RCT_EXPORT_METHOD(initNativeReactNavigationNewFrameTracking : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) diff --git a/packages/core/ios/RNSentryStart.h b/packages/core/ios/RNSentryStart.h new file mode 100644 index 0000000000..bc5adf35af --- /dev/null +++ b/packages/core/ios/RNSentryStart.h @@ -0,0 +1,20 @@ +#import +#import + +@interface RNSentryStart : NSObject +SENTRY_NO_INIT + ++ (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options + error:(NSError *_Nonnull *_Nonnull)errorPointer; + +/** + * @experimental + * Inits and configures Sentry for React Native applications. Make sure to + * set a valid DSN. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)); + +@end diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m new file mode 100644 index 0000000000..c364b6c630 --- /dev/null +++ b/packages/core/ios/RNSentryStart.m @@ -0,0 +1,192 @@ +#import "RNSentryStart.h" +#import "RNSentryReplay.h" +#import "RNSentryVersion.h" + +#import +#import +#import + +@implementation RNSentryStart + ++ (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)) +{ + NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; + [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; + [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME + version:REACT_NATIVE_SDK_PACKAGE_VERSION]; + + [SentrySDK startWithOptions:options]; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay postInit]; +#endif + + [self postDidBecomeActiveNotification]; +} + ++ (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options + error:(NSError *_Nonnull *_Nonnull)errorPointer +{ + SentryBeforeSendEventCallback beforeSend = ^SentryEvent *(SentryEvent *event) + { + // We don't want to send an event after startup that came from a Unhandled JS Exception of + // react native Because we sent it already before the app crashed. + if (nil != event.exceptions.firstObject.type && + [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location + != NSNotFound) { + return nil; + } + + [self setEventOriginTag:event]; + + return event; + }; + + NSMutableDictionary *mutableOptions = [options mutableCopy]; + [mutableOptions setValue:beforeSend forKey:@"beforeSend"]; + + // remove performance traces sample rate and traces sampler since we don't want to synchronize + // these configurations to the Native SDKs. The user could tho initialize the SDK manually and + // set themselves. + [mutableOptions removeObjectForKey:@"tracesSampleRate"]; + [mutableOptions removeObjectForKey:@"tracesSampler"]; + [mutableOptions removeObjectForKey:@"enableTracing"]; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay updateOptions:mutableOptions]; +#endif + + SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions + didFailWithError:errorPointer]; + if (*errorPointer != nil) { + return nil; + } + + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; + NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; + sentryOptions.beforeBreadcrumb + = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) + { + NSString *url = breadcrumb.data[@"url"] ?: @""; + + if ([@"http" isEqualToString:breadcrumb.type] + && ((dsn != nil && [url hasPrefix:dsn]) + || (devServerUrl != nil && [url hasPrefix:devServerUrl]))) { + return nil; + } + return breadcrumb; + }; + + // JS options.enableNativeCrashHandling equals to native options.enableCrashHandler + if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) { + BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue]; + + if (!enableNativeCrashHandling) { + NSMutableArray *integrations = sentryOptions.integrations.mutableCopy; + [integrations removeObject:@"SentryCrashIntegration"]; + sentryOptions.integrations = integrations; + } + } + + // Set spotlight option + if ([mutableOptions valueForKey:@"spotlight"] != nil) { + id spotlightValue = [mutableOptions valueForKey:@"spotlight"]; + if ([spotlightValue isKindOfClass:[NSString class]]) { + NSLog(@"Using Spotlight on address: %@", spotlightValue); + sentryOptions.enableSpotlight = true; + sentryOptions.spotlightUrl = spotlightValue; + } else if ([spotlightValue isKindOfClass:[NSNumber class]]) { + sentryOptions.enableSpotlight = [spotlightValue boolValue]; + id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"]; + if (defaultSpotlightUrl != nil) { + sentryOptions.spotlightUrl = defaultSpotlightUrl; + } + } + } + + // Enable the App start and Frames tracking measurements + if ([mutableOptions valueForKey:@"enableAutoPerformanceTracing"] != nil) { + BOOL enableAutoPerformanceTracing = + [mutableOptions[@"enableAutoPerformanceTracing"] boolValue]; + PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = enableAutoPerformanceTracing; +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = enableAutoPerformanceTracing; +#endif + } + + // Failed requests can only be enabled in one SDK to avoid duplicates + sentryOptions.enableCaptureFailedRequests = NO; + + return sentryOptions; +} + ++ (void)setEventOriginTag:(SentryEvent *)event +{ + if (event.sdk != nil) { + NSString *sdkName = event.sdk[@"name"]; + + // If the event is from react native, it gets set + // there and we do not handle it here. + if ([sdkName isEqual:NATIVE_SDK_NAME]) { + [self setEventEnvironmentTag:event origin:@"ios" environment:@"native"]; + } + } +} + ++ (void)setEventEnvironmentTag:(SentryEvent *)event + origin:(NSString *)origin + environment:(NSString *)environment +{ + NSMutableDictionary *newTags = [NSMutableDictionary new]; + + if (nil != event.tags && [event.tags count] > 0) { + [newTags addEntriesFromDictionary:event.tags]; + } + if (nil != origin) { + [newTags setValue:origin forKey:@"event.origin"]; + } + if (nil != environment) { + [newTags setValue:environment forKey:@"event.environment"]; + } + + event.tags = newTags; +} + ++ (NSString *_Nullable)getURLFromDSN:(NSString *)dsn +{ + NSURL *url = [NSURL URLWithString:dsn]; + if (!url) { + return nil; + } + return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; +} + +static bool sentHybridSdkDidBecomeActive = NO; + ++ (void)postDidBecomeActiveNotification +{ +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + BOOL appIsActive = + [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; +#else + BOOL appIsActive = [[NSApplication sharedApplication] isActive]; +#endif + + // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive + // notification, send it. + if (appIsActive && !sentHybridSdkDidBecomeActive + && (PrivateSentrySDKOnly.options.enableAutoSessionTracking + || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { + // Updates Native App State Manager + // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentryAppStateManager.m#L136 + // Triggers Session Tracker + // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentrySessionTracker.m#L144 + [[NSNotificationCenter defaultCenter] postNotificationName:@"SentryHybridSdkDidBecomeActive" + object:nil]; + + sentHybridSdkDidBecomeActive = true; + } +} + +@end From 2cb7eb2c718a1720435a4bb346f73424bf3eb06c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 22 Jan 2025 09:40:27 +0200 Subject: [PATCH 02/21] ref(android): Extracts Android native initialization to standalone structures (#4445) * Extract Android SDK Init * Update tests * Adds changelog * Fix lint issues * Rename RNSentryStart instance for clarity * Converts RNSentryStart to utility class * Update CHANGELOG.md --------- Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> --- CHANGELOG.md | 1 + .../io/sentry/react/RNSentryModuleImplTest.kt | 166 ---------- .../java/io/sentry/react/RNSentryStartTest.kt | 191 +++++++++++ .../io/sentry/react/RNSentryModuleImpl.java | 282 +--------------- .../java/io/sentry/react/RNSentryStart.java | 311 ++++++++++++++++++ 5 files changed, 505 insertions(+), 446 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 226a44056b..1a40b6ffbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Initialize `RNSentryTimeToDisplay` during native module `init` on iOS ([#4443](https://github.com/getsentry/sentry-react-native/pull/4443)) - Extract iOS native initialization to standalone structures ([#4442](https://github.com/getsentry/sentry-react-native/pull/4442)) +- Extract Android native initialization to standalone structures ([#4445](https://github.com/getsentry/sentry-react-native/pull/4445)) ### Dependencies diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt index adffbf78ad..34af996a76 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt @@ -3,20 +3,13 @@ package io.sentry.react import android.content.pm.PackageInfo import android.content.pm.PackageManager import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.WritableMap -import com.facebook.react.common.JavascriptException -import io.sentry.Breadcrumb import io.sentry.ILogger import io.sentry.SentryLevel -import io.sentry.android.core.SentryAndroidOptions import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -103,163 +96,4 @@ class RNSentryModuleImplTest { val capturedMap = writableMapCaptor.value assertEquals(false, capturedMap.getBoolean("has_fetched")) } - - @Test - fun `when the spotlight option is enabled, the spotlight SentryAndroidOption is set to true and the default url is used`() { - val options = - JavaOnlyMap.of( - "spotlight", - true, - "defaultSidecarUrl", - "http://localhost:8969/teststream", - ) - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assert(actualOptions.isEnableSpotlight) - assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) - } - - @Test - fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { - val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assert(actualOptions.isEnableSpotlight) - assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) - } - - @Test - fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { - val options = JavaOnlyMap.of("spotlight", false) - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assertFalse(actualOptions.isEnableSpotlight) - } - - @Test - fun `the JavascriptException is added to the ignoredExceptionsForType list on initialisation`() { - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, JavaOnlyMap.of(), logger) - assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) - } - - @Test - fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - "http://localhost:8081", - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "https://def.ingest.sentry.io/1234567") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertNull("Breadcrumb should be filtered out", result) - } - - @Test - fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() { - val mockDevServerUrl = "http://localhost:8081" - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - mockDevServerUrl, - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", mockDevServerUrl) - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertNull("Breadcrumb should be filtered out", result) - } - - @Test - fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() { - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - "http://localhost:8081", - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { - val options = SentryAndroidOptions() - module.getSentryAndroidOptions(options, JavaOnlyMap(), logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { - val options = SentryAndroidOptions() - val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { - val options = SentryAndroidOptions() - val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } } diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt new file mode 100644 index 0000000000..c2ee6f1d88 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt @@ -0,0 +1,191 @@ +package io.sentry.react + +import android.app.Activity +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.common.JavascriptException +import io.sentry.Breadcrumb +import io.sentry.ILogger +import io.sentry.android.core.SentryAndroidOptions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations + +@RunWith(JUnit4::class) +class RNSentryStartTest { + private lateinit var logger: ILogger + + private lateinit var activity: Activity + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + logger = mock(ILogger::class.java) + activity = mock(Activity::class.java) + } + + @Test + fun `when the spotlight option is enabled, the spotlight SentryAndroidOption is set to true and the default url is used`() { + val options = + JavaOnlyMap.of( + "spotlight", + true, + "defaultSidecarUrl", + "http://localhost:8969/teststream", + ) + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, activity, logger) + assert(actualOptions.isEnableSpotlight) + assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) + } + + @Test + fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { + val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, activity, logger) + assert(actualOptions.isEnableSpotlight) + assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) + } + + @Test + fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { + val options = JavaOnlyMap.of("spotlight", false) + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, activity, logger) + assertFalse(actualOptions.isEnableSpotlight) + } + + @Test + fun `the JavascriptException is added to the ignoredExceptionsForType list on initialisation`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, JavaOnlyMap.of(), activity, logger) + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + } + + @Test + fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + "http://localhost:8081", + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "https://def.ingest.sentry.io/1234567") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertNull("Breadcrumb should be filtered out", result) + } + + @Test + fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() { + val mockDevServerUrl = "http://localhost:8081" + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + mockDevServerUrl, + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", mockDevServerUrl) + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertNull("Breadcrumb should be filtered out", result) + } + + @Test + fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + "http://localhost:8081", + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { + val options = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(options, JavaOnlyMap(), activity, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") + RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") + RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 486ae72c48..3ffc3eb617 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -20,13 +20,11 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; -import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.common.JavascriptException; import com.facebook.react.modules.core.DeviceEventManagerModule; import io.sentry.Breadcrumb; import io.sentry.HubAdapter; @@ -34,25 +32,16 @@ import io.sentry.IScope; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; -import io.sentry.Integration; import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; -import io.sentry.SentryEvent; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.SentryReplayOptions; -import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.AndroidProfiler; -import io.sentry.android.core.AnrIntegration; -import io.sentry.android.core.BuildConfig; import io.sentry.android.core.BuildInfoProvider; -import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.InternalSentrySdk; -import io.sentry.android.core.NdkIntegration; -import io.sentry.android.core.SentryAndroid; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.ViewHierarchyEventProcessor; @@ -61,11 +50,8 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; -import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; -import io.sentry.react.replay.RNSentryReplayMask; -import io.sentry.react.replay.RNSentryReplayUnmask; import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; @@ -77,8 +63,6 @@ import java.io.FileReader; import java.io.IOException; import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Iterator; @@ -178,216 +162,12 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) { } public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { - SentryAndroid.init( - this.getReactApplicationContext(), - options -> getSentryAndroidOptions(options, rnOptions, logger)); + RNSentryStart.startWithOptions( + this.getReactApplicationContext(), rnOptions, getCurrentActivity(), logger); promise.resolve(true); } - protected void getSentryAndroidOptions( - @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable SdkVersion sdkVersion = options.getSdkVersion(); - if (sdkVersion == null) { - sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); - } else { - sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); - } - sdkVersion.addPackage( - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - - options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); - options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); - - if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { - options.setDebug(true); - } - if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) { - String dsn = rnOptions.getString("dsn"); - logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn)); - options.setDsn(dsn); - } else { - // SentryAndroid needs an empty string fallback for the dsn. - options.setDsn(""); - } - if (rnOptions.hasKey("sampleRate")) { - options.setSampleRate(rnOptions.getDouble("sampleRate")); - } - if (rnOptions.hasKey("sendClientReports")) { - options.setSendClientReports(rnOptions.getBoolean("sendClientReports")); - } - if (rnOptions.hasKey("maxBreadcrumbs")) { - options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs")); - } - if (rnOptions.hasKey("maxCacheItems")) { - options.setMaxCacheItems(rnOptions.getInt("maxCacheItems")); - } - if (rnOptions.hasKey("environment") && rnOptions.getString("environment") != null) { - options.setEnvironment(rnOptions.getString("environment")); - } - if (rnOptions.hasKey("release") && rnOptions.getString("release") != null) { - options.setRelease(rnOptions.getString("release")); - } - if (rnOptions.hasKey("dist") && rnOptions.getString("dist") != null) { - options.setDist(rnOptions.getString("dist")); - } - if (rnOptions.hasKey("enableAutoSessionTracking")) { - options.setEnableAutoSessionTracking(rnOptions.getBoolean("enableAutoSessionTracking")); - } - if (rnOptions.hasKey("sessionTrackingIntervalMillis")) { - options.setSessionTrackingIntervalMillis(rnOptions.getInt("sessionTrackingIntervalMillis")); - } - if (rnOptions.hasKey("shutdownTimeout")) { - options.setShutdownTimeoutMillis(rnOptions.getInt("shutdownTimeout")); - } - if (rnOptions.hasKey("enableNdkScopeSync")) { - options.setEnableScopeSync(rnOptions.getBoolean("enableNdkScopeSync")); - } - if (rnOptions.hasKey("attachStacktrace")) { - options.setAttachStacktrace(rnOptions.getBoolean("attachStacktrace")); - } - if (rnOptions.hasKey("attachThreads")) { - // JS use top level stacktrace and android attaches Threads which hides them so - // by default we hide. - options.setAttachThreads(rnOptions.getBoolean("attachThreads")); - } - if (rnOptions.hasKey("attachScreenshot")) { - options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); - } - if (rnOptions.hasKey("attachViewHierarchy")) { - options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); - } - if (rnOptions.hasKey("sendDefaultPii")) { - options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); - } - if (rnOptions.hasKey("maxQueueSize")) { - options.setMaxQueueSize(rnOptions.getInt("maxQueueSize")); - } - if (rnOptions.hasKey("enableNdk")) { - options.setEnableNdk(rnOptions.getBoolean("enableNdk")); - } - if (rnOptions.hasKey("spotlight")) { - if (rnOptions.getType("spotlight") == ReadableType.Boolean) { - options.setEnableSpotlight(rnOptions.getBoolean("spotlight")); - options.setSpotlightConnectionUrl(rnOptions.getString("defaultSidecarUrl")); - } else if (rnOptions.getType("spotlight") == ReadableType.String) { - options.setEnableSpotlight(true); - options.setSpotlightConnectionUrl(rnOptions.getString("spotlight")); - } - } - - SentryReplayOptions replayOptions = getReplayOptions(rnOptions); - options.setSessionReplay(replayOptions); - if (isReplayEnabled(replayOptions)) { - options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); - } - - // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - String dsn = getURLFromDSN(rnOptions.getString("dsn")); - String devServerUrl = rnOptions.getString("devServerUrl"); - options.setBeforeBreadcrumb( - (breadcrumb, hint) -> { - Object urlObject = breadcrumb.getData("url"); - String url = urlObject instanceof String ? (String) urlObject : ""; - if ("http".equals(breadcrumb.getType()) - && ((dsn != null && url.startsWith(dsn)) - || (devServerUrl != null && url.startsWith(devServerUrl)))) { - return null; - } - return breadcrumb; - }); - - // React native internally throws a JavascriptException. - // we want to ignore it on the native side to avoid sending it twice. - options.addIgnoredExceptionForType(JavascriptException.class); - - options.setBeforeSend( - (event, hint) -> { - setEventOriginTag(event); - addPackages(event, options.getSdkVersion()); - - return event; - }); - - if (rnOptions.hasKey("enableNativeCrashHandling") - && !rnOptions.getBoolean("enableNativeCrashHandling")) { - final List integrations = options.getIntegrations(); - for (final Integration integration : integrations) { - if (integration instanceof UncaughtExceptionHandlerIntegration - || integration instanceof AnrIntegration - || integration instanceof NdkIntegration) { - integrations.remove(integration); - } - } - } - logger.log( - SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); - - final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); - final Activity currentActivity = getCurrentActivity(); - if (currentActivity != null) { - currentActivityHolder.setActivity(currentActivity); - } - } - - private boolean isReplayEnabled(SentryReplayOptions replayOptions) { - return replayOptions.getSessionSampleRate() != null - || replayOptions.getOnErrorSampleRate() != null; - } - - private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = - new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - @NotNull - final SentryReplayOptions androidReplayOptions = - new SentryReplayOptions(false, replaySdkVersion); - - if (!(rnOptions.hasKey("replaysSessionSampleRate") - || rnOptions.hasKey("replaysOnErrorSampleRate"))) { - return androidReplayOptions; - } - - androidReplayOptions.setSessionSampleRate( - rnOptions.hasKey("replaysSessionSampleRate") - ? rnOptions.getDouble("replaysSessionSampleRate") - : null); - androidReplayOptions.setOnErrorSampleRate( - rnOptions.hasKey("replaysOnErrorSampleRate") - ? rnOptions.getDouble("replaysOnErrorSampleRate") - : null); - - if (!rnOptions.hasKey("mobileReplayOptions")) { - return androidReplayOptions; - } - @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); - if (rnMobileReplayOptions == null) { - return androidReplayOptions; - } - - androidReplayOptions.setMaskAllText( - !rnMobileReplayOptions.hasKey("maskAllText") - || rnMobileReplayOptions.getBoolean("maskAllText")); - androidReplayOptions.setMaskAllImages( - !rnMobileReplayOptions.hasKey("maskAllImages") - || rnMobileReplayOptions.getBoolean("maskAllImages")); - - final boolean redactVectors = - !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); - if (redactVectors) { - androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg - } - - androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); - androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); - - return androidReplayOptions; - } - public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } @@ -974,51 +754,6 @@ public void crashedLastRun(Promise promise) { promise.resolve(Sentry.isCrashedLastRun()); } - private void setEventOriginTag(SentryEvent event) { - // We hardcode native-java as only java events are processed by the Android SDK. - SdkVersion sdk = event.getSdk(); - if (sdk != null) { - switch (sdk.getName()) { - case RNSentryVersion.NATIVE_SDK_NAME: - setEventEnvironmentTag(event, "native"); - break; - case RNSentryVersion.ANDROID_SDK_NAME: - setEventEnvironmentTag(event, "java"); - break; - default: - break; - } - } - } - - private void setEventEnvironmentTag(SentryEvent event, String environment) { - event.setTag("event.origin", "android"); - event.setTag("event.environment", environment); - } - - private void addPackages(SentryEvent event, SdkVersion sdk) { - SdkVersion eventSdk = event.getSdk(); - if (eventSdk != null - && "sentry.javascript.react-native".equals(eventSdk.getName()) - && sdk != null) { - List sentryPackages = sdk.getPackages(); - if (sentryPackages != null) { - for (SentryPackage sentryPackage : sentryPackages) { - eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); - } - } - - List integrations = sdk.getIntegrations(); - if (integrations != null) { - for (String integration : integrations) { - eventSdk.addIntegration(integration); - } - } - - event.setSdk(eventSdk); - } - } - private boolean checkAndroidXAvailability() { try { Class.forName("androidx.core.app.FrameMetricsAggregator"); @@ -1032,17 +767,4 @@ private boolean checkAndroidXAvailability() { private boolean isFrameMetricsAggregatorAvailable() { return androidXAvailable && frameMetricsAggregator != null; } - - public static @Nullable String getURLFromDSN(@Nullable String dsn) { - if (dsn == null) { - return null; - } - URI uri = null; - try { - uri = new URI(dsn); - } catch (URISyntaxException e) { - return null; - } - return uri.getScheme() + "://" + uri.getHost(); - } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java new file mode 100644 index 0000000000..263633c4a8 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -0,0 +1,311 @@ +package io.sentry.react; + +import android.app.Activity; +import android.content.Context; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.common.JavascriptException; +import io.sentry.ILogger; +import io.sentry.Integration; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryReplayOptions; +import io.sentry.UncaughtExceptionHandlerIntegration; +import io.sentry.android.core.AnrIntegration; +import io.sentry.android.core.BuildConfig; +import io.sentry.android.core.CurrentActivityHolder; +import io.sentry.android.core.NdkIntegration; +import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryPackage; +import io.sentry.react.replay.RNSentryReplayMask; +import io.sentry.react.replay.RNSentryReplayUnmask; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RNSentryStart { + + private RNSentryStart() { + throw new AssertionError("Utility class should not be instantiated"); + } + + public static void startWithOptions( + @NotNull final Context context, + @NotNull final ReadableMap rnOptions, + @Nullable Activity currentActivity, + @NotNull ILogger logger) { + SentryAndroid.init( + context, options -> getSentryAndroidOptions(options, rnOptions, currentActivity, logger)); + } + + static void getSentryAndroidOptions( + @NotNull SentryAndroidOptions options, + @NotNull ReadableMap rnOptions, + @Nullable Activity currentActivity, + ILogger logger) { + @Nullable SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion == null) { + sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); + } else { + sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); + } + sdkVersion.addPackage( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + + options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); + options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); + options.setSdkVersion(sdkVersion); + + if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { + options.setDebug(true); + } + if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) { + String dsn = rnOptions.getString("dsn"); + logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn)); + options.setDsn(dsn); + } else { + // SentryAndroid needs an empty string fallback for the dsn. + options.setDsn(""); + } + if (rnOptions.hasKey("sampleRate")) { + options.setSampleRate(rnOptions.getDouble("sampleRate")); + } + if (rnOptions.hasKey("sendClientReports")) { + options.setSendClientReports(rnOptions.getBoolean("sendClientReports")); + } + if (rnOptions.hasKey("maxBreadcrumbs")) { + options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs")); + } + if (rnOptions.hasKey("maxCacheItems")) { + options.setMaxCacheItems(rnOptions.getInt("maxCacheItems")); + } + if (rnOptions.hasKey("environment") && rnOptions.getString("environment") != null) { + options.setEnvironment(rnOptions.getString("environment")); + } + if (rnOptions.hasKey("release") && rnOptions.getString("release") != null) { + options.setRelease(rnOptions.getString("release")); + } + if (rnOptions.hasKey("dist") && rnOptions.getString("dist") != null) { + options.setDist(rnOptions.getString("dist")); + } + if (rnOptions.hasKey("enableAutoSessionTracking")) { + options.setEnableAutoSessionTracking(rnOptions.getBoolean("enableAutoSessionTracking")); + } + if (rnOptions.hasKey("sessionTrackingIntervalMillis")) { + options.setSessionTrackingIntervalMillis(rnOptions.getInt("sessionTrackingIntervalMillis")); + } + if (rnOptions.hasKey("shutdownTimeout")) { + options.setShutdownTimeoutMillis(rnOptions.getInt("shutdownTimeout")); + } + if (rnOptions.hasKey("enableNdkScopeSync")) { + options.setEnableScopeSync(rnOptions.getBoolean("enableNdkScopeSync")); + } + if (rnOptions.hasKey("attachStacktrace")) { + options.setAttachStacktrace(rnOptions.getBoolean("attachStacktrace")); + } + if (rnOptions.hasKey("attachThreads")) { + // JS use top level stacktrace and android attaches Threads which hides them so + // by default we hide. + options.setAttachThreads(rnOptions.getBoolean("attachThreads")); + } + if (rnOptions.hasKey("attachScreenshot")) { + options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); + } + if (rnOptions.hasKey("attachViewHierarchy")) { + options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); + } + if (rnOptions.hasKey("sendDefaultPii")) { + options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); + } + if (rnOptions.hasKey("maxQueueSize")) { + options.setMaxQueueSize(rnOptions.getInt("maxQueueSize")); + } + if (rnOptions.hasKey("enableNdk")) { + options.setEnableNdk(rnOptions.getBoolean("enableNdk")); + } + if (rnOptions.hasKey("spotlight")) { + if (rnOptions.getType("spotlight") == ReadableType.Boolean) { + options.setEnableSpotlight(rnOptions.getBoolean("spotlight")); + options.setSpotlightConnectionUrl(rnOptions.getString("defaultSidecarUrl")); + } else if (rnOptions.getType("spotlight") == ReadableType.String) { + options.setEnableSpotlight(true); + options.setSpotlightConnectionUrl(rnOptions.getString("spotlight")); + } + } + + SentryReplayOptions replayOptions = getReplayOptions(rnOptions); + options.setSessionReplay(replayOptions); + if (isReplayEnabled(replayOptions)) { + options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); + } + + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + String dsn = getURLFromDSN(rnOptions.getString("dsn")); + String devServerUrl = rnOptions.getString("devServerUrl"); + options.setBeforeBreadcrumb( + (breadcrumb, hint) -> { + Object urlObject = breadcrumb.getData("url"); + String url = urlObject instanceof String ? (String) urlObject : ""; + if ("http".equals(breadcrumb.getType()) + && ((dsn != null && url.startsWith(dsn)) + || (devServerUrl != null && url.startsWith(devServerUrl)))) { + return null; + } + return breadcrumb; + }); + + // React native internally throws a JavascriptException. + // we want to ignore it on the native side to avoid sending it twice. + options.addIgnoredExceptionForType(JavascriptException.class); + + options.setBeforeSend( + (event, hint) -> { + setEventOriginTag(event); + addPackages(event, options.getSdkVersion()); + + return event; + }); + + if (rnOptions.hasKey("enableNativeCrashHandling") + && !rnOptions.getBoolean("enableNativeCrashHandling")) { + final List integrations = options.getIntegrations(); + for (final Integration integration : integrations) { + if (integration instanceof UncaughtExceptionHandlerIntegration + || integration instanceof AnrIntegration + || integration instanceof NdkIntegration) { + integrations.remove(integration); + } + } + } + logger.log( + SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); + + setCurrentActivity(currentActivity); + } + + private static void setCurrentActivity(Activity currentActivity) { + final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); + if (currentActivity != null) { + currentActivityHolder.setActivity(currentActivity); + } + } + + private static boolean isReplayEnabled(SentryReplayOptions replayOptions) { + return replayOptions.getSessionSampleRate() != null + || replayOptions.getOnErrorSampleRate() != null; + } + + private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { + final SdkVersion replaySdkVersion = + new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + @NotNull + final SentryReplayOptions androidReplayOptions = + new SentryReplayOptions(false, replaySdkVersion); + + if (!(rnOptions.hasKey("replaysSessionSampleRate") + || rnOptions.hasKey("replaysOnErrorSampleRate"))) { + return androidReplayOptions; + } + + androidReplayOptions.setSessionSampleRate( + rnOptions.hasKey("replaysSessionSampleRate") + ? rnOptions.getDouble("replaysSessionSampleRate") + : null); + androidReplayOptions.setOnErrorSampleRate( + rnOptions.hasKey("replaysOnErrorSampleRate") + ? rnOptions.getDouble("replaysOnErrorSampleRate") + : null); + + if (!rnOptions.hasKey("mobileReplayOptions")) { + return androidReplayOptions; + } + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + if (rnMobileReplayOptions == null) { + return androidReplayOptions; + } + + androidReplayOptions.setMaskAllText( + !rnMobileReplayOptions.hasKey("maskAllText") + || rnMobileReplayOptions.getBoolean("maskAllText")); + androidReplayOptions.setMaskAllImages( + !rnMobileReplayOptions.hasKey("maskAllImages") + || rnMobileReplayOptions.getBoolean("maskAllImages")); + + final boolean redactVectors = + !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); + if (redactVectors) { + androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg + } + + androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); + androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); + + return androidReplayOptions; + } + + private static void setEventOriginTag(SentryEvent event) { + // We hardcode native-java as only java events are processed by the Android SDK. + SdkVersion sdk = event.getSdk(); + if (sdk != null) { + switch (sdk.getName()) { + case RNSentryVersion.NATIVE_SDK_NAME: + setEventEnvironmentTag(event, "native"); + break; + case RNSentryVersion.ANDROID_SDK_NAME: + setEventEnvironmentTag(event, "java"); + break; + default: + break; + } + } + } + + private static void setEventEnvironmentTag(SentryEvent event, String environment) { + event.setTag("event.origin", "android"); + event.setTag("event.environment", environment); + } + + private static void addPackages(SentryEvent event, SdkVersion sdk) { + SdkVersion eventSdk = event.getSdk(); + if (eventSdk != null + && "sentry.javascript.react-native".equals(eventSdk.getName()) + && sdk != null) { + List sentryPackages = sdk.getPackages(); + if (sentryPackages != null) { + for (SentryPackage sentryPackage : sentryPackages) { + eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); + } + } + + List integrations = sdk.getIntegrations(); + if (integrations != null) { + for (String integration : integrations) { + eventSdk.addIntegration(integration); + } + } + + event.setSdk(eventSdk); + } + } + + private static @Nullable String getURLFromDSN(@Nullable String dsn) { + if (dsn == null) { + return null; + } + URI uri = null; + try { + uri = new URI(dsn); + } catch (URISyntaxException e) { + return null; + } + return uri.getScheme() + "://" + uri.getHost(); + } +} From 7144a643afc47a146a5553fe5d358e713d38bdd1 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 22 Jan 2025 14:24:46 +0100 Subject: [PATCH 03/21] feat(experimental): Add native `startWithConfigureOptions` for Apple platforms (#4444) --- CHANGELOG.md | 1 + packages/core/RNSentry.podspec | 2 +- .../project.pbxproj | 8 + ...RNSentryCocoaTesterTests-Bridging-Header.h | 3 + .../RNSentryStartTests.swift | 248 ++++++++++++++++++ .../RNSentryCocoaTesterTests/RNSentryTests.mm | 12 +- packages/core/ios/RNSentry.h | 3 + packages/core/ios/RNSentry.mm | 4 +- packages/core/ios/RNSentrySDK.h | 18 ++ packages/core/ios/RNSentrySDK.m | 17 ++ packages/core/ios/RNSentryStart.h | 6 + packages/core/ios/RNSentryStart.m | 97 ++++--- .../sentryreactnativesample/AppDelegate.mm | 6 + 13 files changed, 380 insertions(+), 45 deletions(-) create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift create mode 100644 packages/core/ios/RNSentrySDK.h create mode 100644 packages/core/ios/RNSentrySDK.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a40b6ffbf..4e9096102d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Rename `navigation.processing` span to more expressive `Navigation dispatch to screen A mounted/navigation cancelled` ([#4423](https://github.com/getsentry/sentry-react-native/pull/4423)) - Add RN SDK package to `sdk.packages` for Cocoa ([#4381](https://github.com/getsentry/sentry-react-native/pull/4381)) +- Add experimental version of `startWithConfigureOptions` for Apple platforms ([#4444](https://github.com/getsentry/sentry-react-native/pull/4444)) ### Internal diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec index 97c4fd315c..3c595c08eb 100644 --- a/packages/core/RNSentry.podspec +++ b/packages/core/RNSentry.podspec @@ -33,7 +33,7 @@ Pod::Spec.new do |s| s.preserve_paths = '*.js' s.source_files = 'ios/**/*.{h,m,mm}' - s.public_header_files = 'ios/RNSentry.h' + s.public_header_files = 'ios/RNSentry.h', 'ios/RNSentrySDK.h' s.compiler_flags = other_cflags diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 112c485d6f..1621383063 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; + 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; @@ -27,6 +28,7 @@ 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; 333B58A82D35BA93000F8D04 /* RNSentryStart.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryStart.h; path = ../ios/RNSentryStart.h; sourceTree = SOURCE_ROOT; }; 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "RNSentryStart+Test.h"; path = "RNSentryCocoaTesterTests/RNSentryStart+Test.h"; sourceTree = SOURCE_ROOT; }; + 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentrySDK.h; path = ../ios/RNSentrySDK.h; sourceTree = SOURCE_ROOT; }; 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = ""; }; 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; @@ -37,6 +39,8 @@ 338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; }; 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; }; 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; }; + 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartTests.swift; sourceTree = ""; }; + 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryVersion.h; path = ../ios/RNSentryVersion.h; sourceTree = SOURCE_ROOT; }; 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryFramesTrackerListenerTests.m; sourceTree = ""; }; 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryFramesTrackerListenerTests.h; sourceTree = ""; }; 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; @@ -90,6 +94,7 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */, @@ -118,6 +123,8 @@ 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */, + 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */, 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */, 333B58A82D35BA93000F8D04 /* RNSentryStart.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, @@ -243,6 +250,7 @@ files = ( AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */, 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, + 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index bc2bdd0304..ba8d8f703d 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -7,3 +7,6 @@ #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" +#import "RNSentrySDK.h" +#import "RNSentryStart.h" +#import "RNSentryVersion.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift new file mode 100644 index 0000000000..b9d12200cf --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift @@ -0,0 +1,248 @@ +import XCTest + +final class RNSentryStartTests: XCTestCase { + + func testStartDoesNotThrowWithoutConfigure() { + RNSentrySDK.start(configureOptions: nil) + } + + func assertReactDefaults(_ actualOptions: Options?) { + XCTAssertFalse(actualOptions!.enableCaptureFailedRequests) + XCTAssertNil(actualOptions!.tracesSampleRate) + XCTAssertNil(actualOptions!.tracesSampler) + XCTAssertFalse(actualOptions!.enableTracing) + } + + func testStartSetsReactDeafults() { + var actualOptions: Options? + + RNSentrySDK.start { options in + actualOptions = options + } + + XCTAssertNotNil(actualOptions, "start have not provided default options or have not executed configure callback") + assertReactDefaults(actualOptions) + } + + func testAutoStartSetsReactDefaults() throws { + try startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + + let actualOptions = PrivateSentrySDKOnly.options + assertReactDefaults(actualOptions) + } + + func testStartEnablesHybridTracing() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + }, + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.enableAutoPerformanceTracing = true + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableAutoPerformanceTracing": true + ]) + } + ] + + // Test each implementation + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + XCTAssertTrue(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) + XCTAssertTrue(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + } + } + + func testStartDisablesHybridTracing() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.enableAutoPerformanceTracing = false + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableAutoPerformanceTracing": false + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + XCTAssertFalse(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) + XCTAssertFalse(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + } + } + + func testStartIgnoresUnhandledJsExceptions() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + let actualEvent = actualOptions.beforeSend!(createUnhandledJsExceptionEvent()) + + XCTAssertNil(actualEvent) + } + } + + func testStartSetsNativeEventOrigin() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + let actualEvent = actualOptions.beforeSend!(createNativeEvent()) + + XCTAssertNotNil(actualEvent) + XCTAssertNotNil(actualEvent!.tags) + XCTAssertEqual(actualEvent!.tags!["event.origin"], "ios") + XCTAssertEqual(actualEvent!.tags!["event.environment"], "native") + } + } + + func testStartDoesNotOverwriteUserBeforeSend() { + var executed = false + + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.beforeSend = { event in + executed = true + return event + } + } + + PrivateSentrySDKOnly.options.beforeSend!(genericEvent()) + + XCTAssertTrue(executed) + } + + func testStartSetsHybridSdkName() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualEvent = captuteTestEvent() + + XCTAssertNotNil(actualEvent) + XCTAssertNotNil(actualEvent!.sdk) + XCTAssertEqual(actualEvent!.sdk!["name"] as! String, NATIVE_SDK_NAME) + + let packages = actualEvent!.sdk!["packages"] as! [[String: String]] + let reactPackage = packages.first { $0["name"] == REACT_NATIVE_SDK_PACKAGE_NAME } + + XCTAssertNotNil(reactPackage) + XCTAssertEqual(reactPackage!["name"], REACT_NATIVE_SDK_PACKAGE_NAME) + XCTAssertEqual(reactPackage!["version"], REACT_NATIVE_SDK_PACKAGE_VERSION) + } + } + + func startFromRN(options: [String: Any]) throws { + var error: NSError? + RNSentryStart.start(options: options, error: &error) + + if let error = error { + throw error + } + } + + func createUnhandledJsExceptionEvent() -> Event { + let event = Event() + event.exceptions = [] + event.exceptions!.append(Exception(value: "Test", type: "Unhandled JS Exception: undefined is not a function")) + return event + } + + func createNativeEvent() -> Event { + let event = Event() + event.sdk = [ + "name": NATIVE_SDK_NAME, + "version": "1.2.3" + ] + return event + } + + func genericEvent() -> Event { + return Event() + } + + func captuteTestEvent() -> Event? { + var actualEvent: Event? + + // This is the closest to the sent event we can get using the actual Sentry start method + let originalBeforeSend = PrivateSentrySDKOnly.options.beforeSend + PrivateSentrySDKOnly.options.beforeSend = { event in + if let originalBeforeSend = originalBeforeSend { + let processedEvent = originalBeforeSend(event) + actualEvent = processedEvent + return processedEvent + } + actualEvent = event + return event + } + + SentrySDK.capture(message: "Test") + + return actualEvent + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm index 9cefc4747a..abe2ae70ce 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm @@ -2,6 +2,7 @@ #import "RNSentryStart+Test.h" #import #import +#import #import #import #import @@ -12,7 +13,7 @@ @interface RNSentryInitNativeSdkTests : XCTestCase @implementation RNSentryInitNativeSdkTests -- (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties +- (void)testStartWithDictionaryRemovesPerformanceProperties { NSError *error = nil; @@ -25,9 +26,8 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties , @"enableTracing" : @YES, } ; -SentryOptions *actualOptions = - [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; - +[RNSentryStart startWithOptions:mockedReactNativeDictionary error:&error]; +SentryOptions *actualOptions = PrivateSentrySDKOnly.options; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertNotNil( @@ -45,8 +45,8 @@ - (void)testCaptureFailedRequestsIsDisabled NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = - [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; + [RNSentryStart startWithOptions:mockedReactNativeDictionary error:&error]; + SentryOptions *actualOptions = PrivateSentrySDKOnly.options; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); diff --git a/packages/core/ios/RNSentry.h b/packages/core/ios/RNSentry.h index 66dc7219ac..c7fb93e0ea 100644 --- a/packages/core/ios/RNSentry.h +++ b/packages/core/ios/RNSentry.h @@ -11,6 +11,9 @@ #import #import +// This import exposes public RNSentrySDK start +#import "RNSentrySDK.h" + typedef int (*SymbolicateCallbackType)(const void *, Dl_info *); @interface diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 6907513da5..69fd287403 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -94,13 +94,11 @@ - (instancetype)init : (RCTPromiseRejectBlock)reject) { NSError *error = nil; - SentryOptions *sentryOptions = [RNSentryStart createOptionsWithDictionary:options error:&error]; + [RNSentryStart startWithOptions:options error:&error]; if (error != nil) { reject(@"SentryReactNative", error.localizedDescription, error); return; } - - [RNSentryStart startWithOptions:sentryOptions]; resolve(@YES); } diff --git a/packages/core/ios/RNSentrySDK.h b/packages/core/ios/RNSentrySDK.h new file mode 100644 index 0000000000..7d3512bb5d --- /dev/null +++ b/packages/core/ios/RNSentrySDK.h @@ -0,0 +1,18 @@ +#import + +@interface RNSentrySDK : NSObject +SENTRY_NO_INIT + +/** + * @experimental + * Inits and configures Sentry for React Native applications. Make sure to + * set a valid DSN. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)startWithConfigureOptions: + (void (^_Nullable)(SentryOptions *_Nonnull options))configureOptions + NS_SWIFT_NAME(start(configureOptions:)); + +@end diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m new file mode 100644 index 0000000000..b7ed6f4a7b --- /dev/null +++ b/packages/core/ios/RNSentrySDK.m @@ -0,0 +1,17 @@ +#import "RNSentrySDK.h" +#import "RNSentryStart.h" + +@implementation RNSentrySDK + ++ (void)startWithConfigureOptions:(void (^)(SentryOptions *options))configureOptions +{ + SentryOptions *options = [[SentryOptions alloc] init]; + [RNSentryStart updateWithReactDefaults:options]; + if (configureOptions != nil) { + configureOptions(options); + } + [RNSentryStart updateWithReactFinals:options]; + [RNSentryStart startWithOptions:options]; +} + +@end diff --git a/packages/core/ios/RNSentryStart.h b/packages/core/ios/RNSentryStart.h index bc5adf35af..01a0617148 100644 --- a/packages/core/ios/RNSentryStart.h +++ b/packages/core/ios/RNSentryStart.h @@ -4,9 +4,15 @@ @interface RNSentryStart : NSObject SENTRY_NO_INIT ++ (void)startWithOptions:(NSDictionary *_Nonnull)javascriptOptions + error:(NSError *_Nullable *_Nullable)errorPointer; + + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options error:(NSError *_Nonnull *_Nonnull)errorPointer; ++ (void)updateWithReactDefaults:(SentryOptions *)options; ++ (void)updateWithReactFinals:(SentryOptions *)options; + /** * @experimental * Inits and configures Sentry for React Native applications. Make sure to diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m index c364b6c630..b3d4d5d77e 100644 --- a/packages/core/ios/RNSentryStart.m +++ b/packages/core/ios/RNSentryStart.m @@ -8,6 +8,16 @@ @implementation RNSentryStart ++ (void)startWithOptions:(NSDictionary *_Nonnull)javascriptOptions + error:(NSError *_Nullable *_Nullable)errorPointer +{ + SentryOptions *options = [self createOptionsWithDictionary:javascriptOptions + error:errorPointer]; + [self updateWithReactDefaults:options]; + [self updateWithReactFinals:options]; + [self startWithOptions:options]; +} + + (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)) { NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; @@ -27,30 +37,7 @@ + (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)) + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options error:(NSError *_Nonnull *_Nonnull)errorPointer { - SentryBeforeSendEventCallback beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception of - // react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - return nil; - } - - [self setEventOriginTag:event]; - - return event; - }; - NSMutableDictionary *mutableOptions = [options mutableCopy]; - [mutableOptions setValue:beforeSend forKey:@"beforeSend"]; - - // remove performance traces sample rate and traces sampler since we don't want to synchronize - // these configurations to the Native SDKs. The user could tho initialize the SDK manually and - // set themselves. - [mutableOptions removeObjectForKey:@"tracesSampleRate"]; - [mutableOptions removeObjectForKey:@"tracesSampler"]; - [mutableOptions removeObjectForKey:@"enableTracing"]; #if SENTRY_TARGET_REPLAY_SUPPORTED [RNSentryReplay updateOptions:mutableOptions]; @@ -63,6 +50,7 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) } // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + // TODO: Migrate for manual init NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; sentryOptions.beforeBreadcrumb @@ -105,20 +93,59 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) } } - // Enable the App start and Frames tracking measurements - if ([mutableOptions valueForKey:@"enableAutoPerformanceTracing"] != nil) { - BOOL enableAutoPerformanceTracing = - [mutableOptions[@"enableAutoPerformanceTracing"] boolValue]; - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#endif - } + return sentryOptions; +} - // Failed requests can only be enabled in one SDK to avoid duplicates - sentryOptions.enableCaptureFailedRequests = NO; +/** + * This function updates the options with RNSentry defaults. These default can be + * overwritten by users during manual native initialization. + */ ++ (void)updateWithReactDefaults:(SentryOptions *)options +{ + // Failed requests are captured only in JS to avoid duplicates + options.enableCaptureFailedRequests = NO; - return sentryOptions; + // Tracing is only enabled in JS to avoid duplicate navigation spans + options.tracesSampleRate = nil; + options.tracesSampler = nil; + options.enableTracing = NO; +} + +/** + * This function updates options with changes RNSentry users should not change + * and so this is applied after the configureOptions callback during manual native initialization. + */ ++ (void)updateWithReactFinals:(SentryOptions *)options +{ + SentryBeforeSendEventCallback userBeforeSend = options.beforeSend; + options.beforeSend = ^SentryEvent *(SentryEvent *event) + { + // Unhandled JS Exception are processed by the SDK on JS layer + // To avoid duplicates we drop them in the native SDKs + if (nil != event.exceptions.firstObject.type && + [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location + != NSNotFound) { + return nil; + } + + [self setEventOriginTag:event]; + if (userBeforeSend == nil) { + return event; + } else { + return userBeforeSend(event); + } + }; + + // App Start Hybrid mode doesn't wait for didFinishLaunchNotification and the + // didBecomeVisibleNotification as they will be missed when auto initializing from JS + // App Start measurements are created right after the tracking starts + PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = options.enableAutoPerformanceTracing; +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + // Frames Tracking Hybrid Mode ensures tracking + // is enabled without tracing enabled in the native SDK + PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode + = options.enableAutoPerformanceTracing; +#endif } + (void)setEventOriginTag:(SentryEvent *)event diff --git a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm index 71a62884ac..2a6a0a0956 100644 --- a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm +++ b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm @@ -9,6 +9,7 @@ # import #endif +#import #import #import @@ -57,6 +58,11 @@ - (BOOL)application:(UIApplication *)application // When the native init is enabled the `autoInitializeNativeSdk` // in JS has to be set to `false` // [self initializeSentry]; + // [RNSentrySDK startWithConfigureOptions:^(SentryOptions *options) { + // options.dsn = + // @"https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561"; + // options.debug = YES; + // }]; self.moduleName = @"sentry-react-native-sample"; // You can add your custom initial props in the dictionary below. From 78506779fe155a29fb893c1cd10a792b30b41928 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 22 Jan 2025 17:55:44 +0100 Subject: [PATCH 04/21] feat: Read `sentry.options.json` during cocoa init (#4447) --- CHANGELOG.md | 1 + .../project.pbxproj | 37 ++++++ ...RNSentryCocoaTesterTests-Bridging-Header.h | 2 +- .../RNSentryStartFromFileTests.swift | 115 ++++++++++++++++++ .../RNSentryCocoaTester/RNSentrySDK+Test.h | 9 ++ .../TestAssets/invalid.options.json | 5 + .../TestAssets/invalid.options.txt | 1 + .../TestAssets/valid.options.json | 4 + packages/core/ios/RNSentrySDK.h | 17 ++- packages/core/ios/RNSentrySDK.m | 56 ++++++++- packages/core/ios/RNSentryStart.m | 5 +- packages/core/scripts/sentry-xcode.sh | 19 +++ .../sentryreactnativesample/AppDelegate.mm | 39 +----- samples/react-native/sentry.options.json | 20 +++ 14 files changed, 287 insertions(+), 43 deletions(-) create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift create mode 100644 packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h create mode 100644 packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json create mode 100644 packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt create mode 100644 packages/core/RNSentryCocoaTester/TestAssets/valid.options.json create mode 100644 samples/react-native/sentry.options.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9096102d..ef2e9ed714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Rename `navigation.processing` span to more expressive `Navigation dispatch to screen A mounted/navigation cancelled` ([#4423](https://github.com/getsentry/sentry-react-native/pull/4423)) - Add RN SDK package to `sdk.packages` for Cocoa ([#4381](https://github.com/getsentry/sentry-react-native/pull/4381)) - Add experimental version of `startWithConfigureOptions` for Apple platforms ([#4444](https://github.com/getsentry/sentry-react-native/pull/4444)) +- Add initialization using `sentry.options.json` for Apple platforms ([#4447](https://github.com/getsentry/sentry-react-native/pull/4447)) ### Internal diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 1621383063..0d82e39ef1 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -12,6 +12,10 @@ 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */; }; + 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */; }; + 339C6C482D3FD9A700CA72ED /* invalid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C462D3FD91900CA72ED /* invalid.options.json */; }; + 339C6C492D3FD9A700CA72ED /* invalid.options.txt in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C452D3FD90200CA72ED /* invalid.options.txt */; }; + 339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; @@ -41,6 +45,11 @@ 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; }; 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartTests.swift; sourceTree = ""; }; 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryVersion.h; path = ../ios/RNSentryVersion.h; sourceTree = SOURCE_ROOT; }; + 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartFromFileTests.swift; sourceTree = ""; }; + 339C6C442D3FD62D00CA72ED /* RNSentrySDK+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentrySDK+Test.h"; sourceTree = ""; }; + 339C6C452D3FD90200CA72ED /* invalid.options.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = invalid.options.txt; sourceTree = ""; }; + 339C6C462D3FD91900CA72ED /* invalid.options.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = invalid.options.json; sourceTree = ""; }; + 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = valid.options.json; sourceTree = ""; }; 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryFramesTrackerListenerTests.m; sourceTree = ""; }; 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryFramesTrackerListenerTests.h; sourceTree = ""; }; 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; @@ -75,6 +84,7 @@ 3360896929524163007C7730 = { isa = PBXGroup; children = ( + 339C6C432D3FD41C00CA72ED /* TestAssets */, 33AFE0122B8F319000AAB120 /* RNSentry */, 3360899029524164007C7730 /* RNSentryCocoaTesterTests */, 3360897329524163007C7730 /* Products */, @@ -94,6 +104,7 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */, 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, @@ -120,9 +131,20 @@ path = Replay; sourceTree = ""; }; + 339C6C432D3FD41C00CA72ED /* TestAssets */ = { + isa = PBXGroup; + children = ( + 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */, + 339C6C462D3FD91900CA72ED /* invalid.options.json */, + 339C6C452D3FD90200CA72ED /* invalid.options.txt */, + ); + path = TestAssets; + sourceTree = ""; + }; 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 339C6C442D3FD62D00CA72ED /* RNSentrySDK+Test.h */, 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */, 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */, 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */, @@ -157,6 +179,7 @@ 3360898929524164007C7730 /* Sources */, BB7D14838753E6599863899B /* Frameworks */, CC7959F3721CB3AD7CB6A047 /* [CP] Copy Pods Resources */, + 339C6C472D3FD99900CA72ED /* Resources */, ); buildRules = ( ); @@ -201,6 +224,19 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 339C6C472D3FD99900CA72ED /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 339C6C482D3FD9A700CA72ED /* invalid.options.json in Resources */, + 339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */, + 339C6C492D3FD9A700CA72ED /* invalid.options.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ 30F19D4E16BEEFEC68733838 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -252,6 +288,7 @@ 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, + 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index ba8d8f703d..08fddcbf8e 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -7,6 +7,6 @@ #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" -#import "RNSentrySDK.h" +#import "RNSentrySDK+Test.h" #import "RNSentryStart.h" #import "RNSentryVersion.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift new file mode 100644 index 0000000000..e0269a5961 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift @@ -0,0 +1,115 @@ +import XCTest + +final class RNSentryStartFromFileTests: XCTestCase { + + func testNoThrowOnMissingOptionsFile() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getNonExistingOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNil(actualOptions.parsedDsn) + } + + func testNoThrowOnInvalidFileType() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getInvalidOptionsTypePath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNil(actualOptions.parsedDsn) + } + + func testNoThrowOnInvalidOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getInvalidOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNotNil(actualOptions.parsedDsn) + XCTAssertEqual(actualOptions.environment, "environment-from-invalid-file") + } + + func testLoadValidOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getValidOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNotNil(actualOptions.parsedDsn) + XCTAssertEqual(actualOptions.environment, "environment-from-valid-file") + } + + func testOptionsFromFileInConfigureOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getValidOptionsPath()) { options in + wasConfigurationCalled = true + XCTAssertEqual(options.environment, "environment-from-valid-file") + } + + XCTAssertTrue(wasConfigurationCalled) + } + + func testOptionsOverwrittenInConfigureOptions() { + RNSentrySDK.start(getValidOptionsPath()) { options in + options.environment = "new-environment" + } + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertEqual(actualOptions.environment, "new-environment") + } + + func getNonExistingOptionsPath() -> String { + return "/non-existing.options.json" + } + + func getInvalidOptionsTypePath() -> String { + guard let path = getTestBundle().path(forResource: "invalid.options", ofType: "txt") else { + fatalError("Could not get invalid type options path") + } + return path + } + + func getInvalidOptionsPath() -> String { + guard let path = getTestBundle().path(forResource: "invalid.options", ofType: "json") else { + fatalError("Could not get invalid options path") + } + return path + } + + func getValidOptionsPath() -> String { + guard let path = getTestBundle().path(forResource: "valid.options", ofType: "json") else { + fatalError("Could not get invalid options path") + } + return path + } + + func getTestBundle() -> Bundle { + let maybeBundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) + guard let bundle = maybeBundle else { + fatalError("Could not find test bundle") + } + return bundle + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h b/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h new file mode 100644 index 0000000000..06da31b42d --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h @@ -0,0 +1,9 @@ +#import "RNSentrySDK.h" + +@interface +RNSentrySDK (Test) + ++ (void)start:(NSString *)path + configureOptions:(void (^)(SentryOptions *_Nonnull options))configureOptions; + +@end diff --git a/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json new file mode 100644 index 0000000000..bf8f2be64c --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json @@ -0,0 +1,5 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "environment": "environment-from-invalid-file", + "invalid-option": 123 +} diff --git a/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt new file mode 100644 index 0000000000..601553b507 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt @@ -0,0 +1 @@ +invalid-options diff --git a/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json b/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json new file mode 100644 index 0000000000..641087d5e8 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json @@ -0,0 +1,4 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "environment": "environment-from-valid-file" +} diff --git a/packages/core/ios/RNSentrySDK.h b/packages/core/ios/RNSentrySDK.h index 7d3512bb5d..232071d9bc 100644 --- a/packages/core/ios/RNSentrySDK.h +++ b/packages/core/ios/RNSentrySDK.h @@ -5,8 +5,21 @@ SENTRY_NO_INIT /** * @experimental - * Inits and configures Sentry for React Native applications. Make sure to - * set a valid DSN. + * Inits and configures Sentry for React Native applications using `sentry.options.json` + * configuration file. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)start; + +/** + * @experimental + * Inits and configures Sentry for React Native applicationsusing `sentry.options.json` + * configuration file and `configureOptions` callback. + * + * The `configureOptions` callback can overwrite the config file options + * and add non-serializable items to the options object. * * @discussion Call this method on the main thread. When calling it from a background thread, the * SDK starts on the main thread async. diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m index b7ed6f4a7b..7d7f4cf9b3 100644 --- a/packages/core/ios/RNSentrySDK.m +++ b/packages/core/ios/RNSentrySDK.m @@ -1,11 +1,65 @@ #import "RNSentrySDK.h" #import "RNSentryStart.h" +static NSString *SENTRY_OPTIONS_RESOURCE_NAME = @"sentry.options"; +static NSString *SENTRY_OPTIONS_RESOURCE_TYPE = @"json"; + @implementation RNSentrySDK ++ (void)start +{ + [self startWithConfigureOptions:nil]; +} + + (void)startWithConfigureOptions:(void (^)(SentryOptions *options))configureOptions { - SentryOptions *options = [[SentryOptions alloc] init]; + NSString *path = [[NSBundle mainBundle] pathForResource:SENTRY_OPTIONS_RESOURCE_NAME + ofType:SENTRY_OPTIONS_RESOURCE_TYPE]; + + [self start:path configureOptions:configureOptions]; +} + ++ (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options))configureOptions +{ + NSError *readError = nil; + NSError *parseError = nil; + NSError *optionsError = nil; + + NSData *_Nullable content = nil; + if (path != nil) { + content = [NSData dataWithContentsOfFile:path options:0 error:&readError]; + } + + NSDictionary *dict = nil; + if (content != nil) { + dict = [NSJSONSerialization JSONObjectWithData:content options:0 error:&parseError]; + } + + if (readError != nil) { + NSLog(@"[RNSentry] Failed to load options from %@, with error: %@", path, + readError.localizedDescription); + } + + if (parseError != nil) { + NSLog(@"[RNSentry] Failed to parse JSON from %@, with error: %@", path, + parseError.localizedDescription); + } + + SentryOptions *options = nil; + if (dict != nil) { + options = [RNSentryStart createOptionsWithDictionary:dict error:&optionsError]; + } + + if (optionsError != nil) { + NSLog(@"[RNSentry] Failed to parse options from %@, with error: %@", path, + optionsError.localizedDescription); + } + + if (options == nil) { + // Fallback in case that options file could not be parsed. + options = [[SentryOptions alloc] init]; + } + [RNSentryStart updateWithReactDefaults:options]; if (configureOptions != nil) { configureOptions(options); diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m index b3d4d5d77e..84e2d83b02 100644 --- a/packages/core/ios/RNSentryStart.m +++ b/packages/core/ios/RNSentryStart.m @@ -50,8 +50,9 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) } // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - // TODO: Migrate for manual init NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; + // TODO: For Auto Init from JS dev server is resolved automatically, for init from options file + // dev server has to be specified manually NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; sentryOptions.beforeBreadcrumb = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) @@ -86,6 +87,8 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) sentryOptions.spotlightUrl = spotlightValue; } else if ([spotlightValue isKindOfClass:[NSNumber class]]) { sentryOptions.enableSpotlight = [spotlightValue boolValue]; + // TODO: For Auto init from JS set automatically for init from options file have to be + // set manually id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"]; if (defaultSpotlightUrl != nil) { sentryOptions.spotlightUrl = defaultSpotlightUrl; diff --git a/packages/core/scripts/sentry-xcode.sh b/packages/core/scripts/sentry-xcode.sh index 78970c4c60..336d393220 100755 --- a/packages/core/scripts/sentry-xcode.sh +++ b/packages/core/scripts/sentry-xcode.sh @@ -51,3 +51,22 @@ fi if [ -f "$SENTRY_COLLECT_MODULES" ]; then /bin/sh "$SENTRY_COLLECT_MODULES" fi + +SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX="Skipping options file copy. To disable this behavior, set SENTRY_COPY_OPTIONS_FILE=false in your environment variables." +SENTRY_OPTIONS_FILE_NAME="sentry.options.json" +SENTRY_OPTIONS_FILE_DESTINATION_PATH="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/$SENTRY_OPTIONS_FILE_NAME" +[ -z "$SENTRY_OPTIONS_FILE_PATH" ] && SENTRY_OPTIONS_FILE_PATH="$RN_PROJECT_ROOT/$SENTRY_OPTIONS_FILE_NAME" +[ -z "$SENTRY_COPY_OPTIONS_FILE" ] && SENTRY_COPY_OPTIONS_FILE=true + +if [ "$SENTRY_COPY_OPTIONS_FILE" = true ]; then + if [[ -z "$CONFIGURATION_BUILD_DIR" ]]; then + echo "[Sentry] CONFIGURATION_BUILD_DIR is not set. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + elif [[ -z "$UNLOCALIZED_RESOURCES_FOLDER_PATH" ]]; then + echo "[Sentry] UNLOCALIZED_RESOURCES_FOLDER_PATH is not set. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + elif [ ! -f "$SENTRY_OPTIONS_FILE_PATH" ]; then + echo "[Sentry] $SENTRY_OPTIONS_FILE_PATH not found. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + else + cp "$SENTRY_OPTIONS_FILE_PATH" "$SENTRY_OPTIONS_FILE_DESTINATION_PATH" + echo "[Sentry] Copied $SENTRY_OPTIONS_FILE_PATH to $SENTRY_OPTIONS_FILE_DESTINATION_PATH" + fi +fi diff --git a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm index 2a6a0a0956..fe0893c5b6 100644 --- a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm +++ b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm @@ -20,49 +20,12 @@ @implementation AppDelegate -- (void)initializeSentry -{ - [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { - // Only options set here will apply to the iOS SDK - // Options from JS are not passed to the iOS SDK when initialized manually - options.dsn = @"https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561"; - options.debug = YES; // Enabled debug when first installing is always helpful - - options.beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception - // of react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - NSLog(@"Unhandled JS Exception"); - return nil; - } - - return event; - }; - - // Enable the App start and Frames tracking measurements - // If this is disabled the app start and frames tracking - // won't be passed from native to JS transactions - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = true; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = true; -#endif - }]; -} - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // When the native init is enabled the `autoInitializeNativeSdk` // in JS has to be set to `false` - // [self initializeSentry]; - // [RNSentrySDK startWithConfigureOptions:^(SentryOptions *options) { - // options.dsn = - // @"https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561"; - // options.debug = YES; - // }]; + // [RNSentrySDK start]; self.moduleName = @"sentry-react-native-sample"; // You can add your custom initial props in the dictionary below. diff --git a/samples/react-native/sentry.options.json b/samples/react-native/sentry.options.json new file mode 100644 index 0000000000..f6465b7923 --- /dev/null +++ b/samples/react-native/sentry.options.json @@ -0,0 +1,20 @@ +{ + "dsn": "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561", + "debug": true, + "environment": "dev", + "enableUserInteractionTracing": true, + "enableAutoSessionTracking": true, + "sessionTrackingIntervalMillis": 30000, + "enableTracing": true, + "tracesSampleRate": 1.0, + "attachStacktrace": true, + "attachScreenshot": true, + "attachViewHierarchy": true, + "enableCaptureFailedRequests": true, + "_release": "myapp@1.2.3+1", + "_dist": 1, + "profilesSampleRate": 1.0, + "replaysSessionSampleRate": 1.0, + "replaysOnErrorSampleRate": 1.0, + "spotlight": true +} From 1e5dbde34550da3be4e105ab195547eef65d89a3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 23 Jan 2025 16:28:14 +0200 Subject: [PATCH 05/21] Adds utility class for converting `JsonObject` to `WritableMap` (#4479) * Convert json object to writable map * Make class/methods package-private(default) --- .../sentry/react/RNSentryJsonConverterTest.kt | 103 ++++++++++++++++++ .../sentry/react/RNSentryJsonConverter.java | 76 +++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt new file mode 100644 index 0000000000..e49aa546f8 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt @@ -0,0 +1,103 @@ +package io.sentry.react + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import io.sentry.react.RNSentryJsonConverter.convertToWritable +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RNSentryJsonConverterTest { + @Test + fun testConvertToWritableWithSimpleJsonObject() { + val jsonObject = + JSONObject().apply { + put("floatKey", 12.3f) + put("doubleKey", 12.3) + put("intKey", 123) + put("stringKey", "test") + put("nullKey", JSONObject.NULL) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + assertNotNull(result) + assertEquals(12.3, result!!.getDouble("floatKey"), 0.0001) + assertEquals(12.3, result.getDouble("doubleKey"), 0.0) + assertEquals(123, result.getInt("intKey")) + assertEquals("test", result.getString("stringKey")) + assertNull(result.getString("nullKey")) + } + + @Test + fun testConvertToWritableWithNestedJsonObject() { + val jsonObject = + JSONObject().apply { + put( + "nested", + JSONObject().apply { + put("key", "value") + }, + ) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + assertNotNull(result) + val nestedMap = result!!.getMap("nested") + assertNotNull(nestedMap) + assertEquals("value", nestedMap!!.getString("key")) + } + + @Test + fun testConvertToWritableWithJsonArray() { + val jsonArray = + JSONArray().apply { + put(1) + put(2.5) + put("string") + put(JSONObject.NULL) + } + + val result: WritableArray = convertToWritable(jsonArray) + + assertEquals(1, result.getInt(0)) + assertEquals(2.5, result.getDouble(1), 0.0) + assertEquals("string", result.getString(2)) + assertNull(result.getString(3)) + } + + @Test + fun testConvertToWritableWithNestedJsonArray() { + val jsonObject = + JSONObject().apply { + put( + "array", + JSONArray().apply { + put( + JSONObject().apply { + put("key1", "value1") + }, + ) + put( + JSONObject().apply { + put("key2", "value2") + }, + ) + }, + ) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + val array = result?.getArray("array") + assertEquals("value1", array?.getMap(0)?.getString("key1")) + assertEquals("value2", array?.getMap(1)?.getString("key2")) + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java new file mode 100644 index 0000000000..44ec324eed --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java @@ -0,0 +1,76 @@ +package io.sentry.react; + +import com.facebook.react.bridge.JavaOnlyArray; +import com.facebook.react.bridge.JavaOnlyMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import java.util.Iterator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +final class RNSentryJsonConverter { + public static final String NAME = "RNSentry.RNSentryJsonConverter"; + + private static final ILogger logger = new AndroidLogger(NAME); + + private RNSentryJsonConverter() { + throw new AssertionError("Utility class should not be instantiated"); + } + + @Nullable + static WritableMap convertToWritable(@NotNull JSONObject jsonObject) { + try { + WritableMap writableMap = new JavaOnlyMap(); + Iterator iterator = jsonObject.keys(); + while (iterator.hasNext()) { + String key = iterator.next(); + Object value = jsonObject.get(key); + if (value instanceof Float || value instanceof Double) { + writableMap.putDouble(key, jsonObject.getDouble(key)); + } else if (value instanceof Number) { + writableMap.putInt(key, jsonObject.getInt(key)); + } else if (value instanceof String) { + writableMap.putString(key, jsonObject.getString(key)); + } else if (value instanceof JSONObject) { + writableMap.putMap(key, convertToWritable(jsonObject.getJSONObject(key))); + } else if (value instanceof JSONArray) { + writableMap.putArray(key, convertToWritable(jsonObject.getJSONArray(key))); + } else if (value == JSONObject.NULL) { + writableMap.putNull(key); + } + } + return writableMap; + } catch (JSONException e) { + logger.log(SentryLevel.ERROR, "Error parsing json object:" + e.getMessage()); + return null; + } + } + + @NotNull + static WritableArray convertToWritable(@NotNull JSONArray jsonArray) throws JSONException { + WritableArray writableArray = new JavaOnlyArray(); + for (int i = 0; i < jsonArray.length(); i++) { + Object value = jsonArray.get(i); + if (value instanceof Float || value instanceof Double) { + writableArray.pushDouble(jsonArray.getDouble(i)); + } else if (value instanceof Number) { + writableArray.pushInt(jsonArray.getInt(i)); + } else if (value instanceof String) { + writableArray.pushString(jsonArray.getString(i)); + } else if (value instanceof JSONObject) { + writableArray.pushMap(convertToWritable(jsonArray.getJSONObject(i))); + } else if (value instanceof JSONArray) { + writableArray.pushArray(convertToWritable(jsonArray.getJSONArray(i))); + } else if (value == JSONObject.NULL) { + writableArray.pushNull(); + } + } + return writableArray; + } +} From 22a5f81ac7afa2899b8921e705518d6101e38a2f Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:55:19 +0100 Subject: [PATCH 06/21] feat: Automatically load `sentry.options.json` file (#4476) --- CHANGELOG.md | 4 + packages/core/jest.config.tools.js | 2 +- packages/core/src/js/tools/metroconfig.ts | 20 +- .../src/js/tools/sentryMetroSerializer.ts | 1 + .../src/js/tools/sentryOptionsSerializer.ts | 104 +++++++++ packages/core/src/js/tools/utils.ts | 4 +- .../tools/sentryOptionsSerializer.test.ts | 209 ++++++++++++++++++ samples/expo/sentry.options.json | 18 ++ 8 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/js/tools/sentryOptionsSerializer.ts create mode 100644 packages/core/test/tools/sentryOptionsSerializer.test.ts create mode 100644 samples/expo/sentry.options.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2e9ed714..6a73f7f4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Load `optionsFile` into the JS bundle during Metro bundle process ([#4476](https://github.com/getsentry/sentry-react-native/pull/4476)) + ### Fixes - Add mechanism field to unhandled rejection errors ([#4457](https://github.com/getsentry/sentry-react-native/pull/4457)) diff --git a/packages/core/jest.config.tools.js b/packages/core/jest.config.tools.js index 5c5902d8a7..996ad05625 100644 --- a/packages/core/jest.config.tools.js +++ b/packages/core/jest.config.tools.js @@ -1,7 +1,7 @@ module.exports = { collectCoverage: true, preset: 'ts-jest', - setupFilesAfterEnv: ['/test/mockConsole.ts'], + setupFilesAfterEnv: ['jest-extended/all', '/test/mockConsole.ts'], globals: { __DEV__: true, }, diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 71c43389a1..e0bd57c178 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -10,6 +10,8 @@ import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; export * from './sentryMetroSerializer'; import { withSentryMiddleware } from './metroMiddleware'; +import { withSentryOptionsFromFile } from './sentryOptionsSerializer'; +import type { MetroCustomSerializer } from './utils'; enableLogger(); @@ -30,6 +32,14 @@ export interface SentryMetroConfigOptions { * @default true */ enableSourceContextInDevelopment?: boolean; + /** + * Load Sentry Options from a file. If `true` it will use the default path. + * If `false` it will not load any options from a file. Only options provided in the code will be used. + * If `string` it will use the provided path. + * + * @default '{projectRoot}/sentry.options.json' + */ + optionsFile?: string | boolean; } export interface SentryExpoConfigOptions { @@ -51,6 +61,7 @@ export function withSentryConfig( annotateReactComponents = false, includeWebReplay = true, enableSourceContextInDevelopment = true, + optionsFile = true, }: SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -68,6 +79,9 @@ export function withSentryConfig( if (enableSourceContextInDevelopment) { newConfig = withSentryMiddleware(newConfig); } + if (optionsFile) { + newConfig = withSentryOptionsFromFile(newConfig, optionsFile); + } return newConfig; } @@ -103,6 +117,10 @@ export function getSentryExpoConfig( newConfig = withSentryMiddleware(newConfig); } + if (options.optionsFile ?? true) { + newConfig = withSentryOptionsFromFile(newConfig, options.optionsFile ?? true); + } + return newConfig; } @@ -155,8 +173,6 @@ export function withSentryBabelTransformer(config: MetroConfig): MetroConfig { }; } -type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; - function withSentryDebugId(config: MetroConfig): MetroConfig { const customSerializer = createSentryMetroSerializer( config.serializer?.customSerializer || undefined, diff --git a/packages/core/src/js/tools/sentryMetroSerializer.ts b/packages/core/src/js/tools/sentryMetroSerializer.ts index fca0979440..feb1e65621 100644 --- a/packages/core/src/js/tools/sentryMetroSerializer.ts +++ b/packages/core/src/js/tools/sentryMetroSerializer.ts @@ -42,6 +42,7 @@ export function unstable_beforeAssetSerializationPlugin({ return [...addDebugIdModule(premodules, debugIdModule)]; } +// TODO: deprecate this and afterwards rename to createSentryDebugIdSerializer /** * Creates a Metro serializer that adds Debug ID module to the plain bundle. * The Debug ID module is a virtual module that provides a debug ID in runtime. diff --git a/packages/core/src/js/tools/sentryOptionsSerializer.ts b/packages/core/src/js/tools/sentryOptionsSerializer.ts new file mode 100644 index 0000000000..f2ab93b383 --- /dev/null +++ b/packages/core/src/js/tools/sentryOptionsSerializer.ts @@ -0,0 +1,104 @@ +import { logger } from '@sentry/core'; +import * as fs from 'fs'; +import type { MetroConfig, Module } from 'metro'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as countLines from 'metro/src/lib/countLines'; +import * as path from 'path'; + +import type { MetroCustomSerializer, VirtualJSOutput } from './utils'; +import { createSet } from './utils'; + +const DEFAULT_OPTIONS_FILE_NAME = 'sentry.options.json'; + +/** + * Loads Sentry options from a file in + */ +export function withSentryOptionsFromFile(config: MetroConfig, optionsFile: string | boolean): MetroConfig { + if (optionsFile === false) { + return config; + } + + const { projectRoot } = config; + if (!projectRoot) { + // eslint-disable-next-line no-console + console.error('[@sentry/react-native/metro] Project root is required to load Sentry options from a file'); + return config; + } + + let optionsPath = path.join(projectRoot, DEFAULT_OPTIONS_FILE_NAME); + if (typeof optionsFile === 'string' && path.isAbsolute(optionsFile)) { + optionsPath = optionsFile; + } else if (typeof optionsFile === 'string') { + optionsPath = path.join(projectRoot, optionsFile); + } + + const originalSerializer = config.serializer?.customSerializer; + if (!originalSerializer) { + // It's okay to bail here because we don't expose this for direct usage, but as part of `withSentryConfig` + // If used directly in RN, the user is responsible for providing a custom serializer first, Expo provides serializer in default config + // eslint-disable-next-line no-console + console.error( + '[@sentry/react-native/metro] `config.serializer.customSerializer` is required to load Sentry options from a file', + ); + return config; + } + + const sentryOptionsSerializer: MetroCustomSerializer = (entryPoint, preModules, graph, options) => { + const sentryOptionsModule = createSentryOptionsModule(optionsPath); + if (sentryOptionsModule) { + (preModules as Module[]).push(sentryOptionsModule); + } + return originalSerializer(entryPoint, preModules, graph, options); + }; + + return { + ...config, + serializer: { + ...config.serializer, + customSerializer: sentryOptionsSerializer, + }, + }; +} + +function createSentryOptionsModule(filePath: string): Module | null { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.debug(`[@sentry/react-native/metro] Sentry options file does not exist at ${filePath}`); + } else { + logger.error(`[@sentry/react-native/metro] Failed to read Sentry options file at ${filePath}`); + } + return null; + } + + let parsedContent: Record; + try { + parsedContent = JSON.parse(content); + } catch (error) { + logger.error(`[@sentry/react-native/metro] Failed to parse Sentry options file at ${filePath}`); + return null; + } + + const minifiedContent = JSON.stringify(parsedContent); + const optionsCode = `var __SENTRY_OPTIONS__=${minifiedContent};`; + + logger.debug(`[@sentry/react-native/metro] Sentry options added to the bundle from file at ${filePath}`); + return { + dependencies: new Map(), + getSource: () => Buffer.from(optionsCode), + inverseDependencies: createSet(), + path: '__sentry-options__', + output: [ + { + type: 'js/script/virtual', + data: { + code: optionsCode, + lineCount: countLines(optionsCode), + map: [], + }, + }, + ], + }; +} diff --git a/packages/core/src/js/tools/utils.ts b/packages/core/src/js/tools/utils.ts index 769dc9abd4..82ff4075e2 100644 --- a/packages/core/src/js/tools/utils.ts +++ b/packages/core/src/js/tools/utils.ts @@ -1,9 +1,11 @@ import * as crypto from 'crypto'; // eslint-disable-next-line import/no-extraneous-dependencies -import type { Module, ReadOnlyGraph, SerializerOptions } from 'metro'; +import type { MetroConfig, Module, ReadOnlyGraph, SerializerOptions } from 'metro'; // eslint-disable-next-line import/no-extraneous-dependencies import type CountingSet from 'metro/src/lib/CountingSet'; +export type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; + // Variant of MixedOutput // https://github.com/facebook/metro/blob/9b85f83c9cc837d8cd897aa7723be7da5b296067/packages/metro/src/DeltaBundler/types.flow.js#L21 export type VirtualJSOutput = { diff --git a/packages/core/test/tools/sentryOptionsSerializer.test.ts b/packages/core/test/tools/sentryOptionsSerializer.test.ts new file mode 100644 index 0000000000..ed946d098a --- /dev/null +++ b/packages/core/test/tools/sentryOptionsSerializer.test.ts @@ -0,0 +1,209 @@ +import { logger } from '@sentry/core'; +import * as fs from 'fs'; +import type { Graph, Module, SerializerOptions } from 'metro'; + +import { withSentryOptionsFromFile } from '../../src/js/tools/sentryOptionsSerializer'; +import { createSet } from '../../src/js/tools/utils'; + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + +const consoleErrorSpy = jest.spyOn(console, 'error'); +const loggerDebugSpy = jest.spyOn(logger, 'debug'); +const loggerErrorSpy = jest.spyOn(logger, 'error'); + +const customSerializerMock = jest.fn(); +let mockedPreModules: Module[] = []; + +describe('Sentry Options Serializer', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedPreModules = createMockedPreModules(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('returns original config if optionsFile is false', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const result = withSentryOptionsFromFile(config(), false); + expect(result).toEqual(config()); + }); + + test('logs error and returns original config if projectRoot is missing', () => { + const config = () => ({ + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const result = withSentryOptionsFromFile(config(), true); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Project root is required')); + expect(result).toEqual(config()); + }); + + test('logs error and returns original config if customSerializer is missing', () => { + const config = () => ({ + projectRoot: '/test', + serializer: {}, + }); + const consoleErrorSpy = jest.spyOn(console, 'error'); + + const result = withSentryOptionsFromFile(config(), true); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('`config.serializer.customSerializer` is required'), + ); + expect(result).toEqual(config()); + }); + + test('adds sentry options module when file exists and is valid JSON', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const mockOptions = { test: 'value' }; + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockOptions)); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(mockedPreModules).toHaveLength(2); + expect(mockedPreModules.at(-1)).toEqual( + expect.objectContaining({ + getSource: expect.any(Function), + path: '__sentry-options__', + output: [ + { + type: 'js/script/virtual', + data: { + code: 'var __SENTRY_OPTIONS__={"test":"value"};', + lineCount: 1, + map: [], + }, + }, + ], + }), + ); + expect(mockedPreModules.at(-1).getSource().toString()).toEqual(mockedPreModules.at(-1).output[0].data.code); + expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('options added to the bundle')); + }); + + test('logs error and does not add module when file does not exist', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw { code: 'ENOENT' }; + }); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('options file does not exist')); + expect(mockedPreModules).toMatchObject(createMockedPreModules()); + }); + + test('logs error and does not add module when file contains invalid JSON', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + (fs.readFileSync as jest.Mock).mockReturnValue('invalid json'); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse Sentry options file')); + expect(mockedPreModules).toMatchObject(createMockedPreModules()); + }); + + test('calls original serializer with correct arguments and returns its result', () => { + const mockedEntryPoint = 'entryPoint'; + const mockedGraph: Graph = jest.fn() as unknown as Graph; + const mockedOptions: SerializerOptions = jest.fn() as unknown as SerializerOptions; + const mockedResult = {}; + const originalSerializer = jest.fn().mockReturnValue(mockedResult); + + const actualConfig = withSentryOptionsFromFile( + { + projectRoot: '/test', + serializer: { + customSerializer: originalSerializer, + }, + }, + true, + ); + const actualResult = actualConfig.serializer?.customSerializer( + mockedEntryPoint, + mockedPreModules, + mockedGraph, + mockedOptions, + ); + + expect(originalSerializer).toHaveBeenCalledWith(mockedEntryPoint, mockedPreModules, mockedGraph, mockedOptions); + expect(actualResult).toEqual(mockedResult); + }); + + test('uses custom file path when optionsFile is a string', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + withSentryOptionsFromFile(config(), 'custom/path.json').serializer?.customSerializer( + null, + mockedPreModules, + null, + null, + ); + withSentryOptionsFromFile(config(), '/absolute/path.json').serializer?.customSerializer( + null, + mockedPreModules, + null, + null, + ); + + expect(fs.readFileSync).toHaveBeenCalledWith('/test/custom/path.json', expect.anything()); + expect(fs.readFileSync).toHaveBeenCalledWith('/absolute/path.json', expect.anything()); + }); +}); + +function createMockedPreModules(): Module[] { + return [createMinimalModule()]; +} + +function createMinimalModule(): Module { + return { + dependencies: new Map(), + getSource: getEmptySource, + inverseDependencies: createSet(), + path: '__sentry-options__', + output: [], + }; +} + +function getEmptySource(): Buffer { + return Buffer.from(''); +} diff --git a/samples/expo/sentry.options.json b/samples/expo/sentry.options.json new file mode 100644 index 0000000000..53ae525bc0 --- /dev/null +++ b/samples/expo/sentry.options.json @@ -0,0 +1,18 @@ +{ + "dsn": "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561", + "debug": true, + "environment": "dev", + "enableUserInteractionTracing": true, + "enableAutoSessionTracking": true, + "sessionTrackingIntervalMillis": 30000, + "enableTracing": true, + "tracesSampleRate": 1.0, + "attachStacktrace": true, + "attachScreenshot": true, + "attachViewHierarchy": true, + "enableCaptureFailedRequests": true, + "profilesSampleRate": 1.0, + "replaysSessionSampleRate": 1.0, + "replaysOnErrorSampleRate": 1.0, + "spotlight": true +} From a1cb36d84366425054c3e8e2ae0105e0acab7117 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 4 Feb 2025 17:08:11 +0200 Subject: [PATCH 07/21] feat(experimental): Initialize Android SDK from json configuration (#4451) --- CHANGELOG.md | 2 + .../androidTest/assets/invalid.options.json | 3 + .../androidTest/assets/invalid.options.txt | 1 + .../androidTest/assets/sentry.options.json | 5 + .../java/io/sentry/react/RNSentrySDKTest.kt | 200 ++++++++++++++++++ ...SentryCompositeOptionsConfigurationTest.kt | 50 +++++ .../java/io/sentry/react/RNSentryStartTest.kt | 92 ++++++-- ...RNSentryCompositeOptionsConfiguration.java | 25 +++ .../io/sentry/react/RNSentryJsonUtils.java | 41 ++++ .../java/io/sentry/react/RNSentrySDK.java | 68 ++++++ .../java/io/sentry/react/RNSentryStart.java | 118 ++++++++--- packages/core/sentry.gradle | 53 ++++- .../reactnative/sample/MainApplication.kt | 29 +-- 13 files changed, 614 insertions(+), 73 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json create mode 100644 packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt create mode 100644 packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json create mode 100644 packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a73f7f4ca..26de1b8c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ - Rename `navigation.processing` span to more expressive `Navigation dispatch to screen A mounted/navigation cancelled` ([#4423](https://github.com/getsentry/sentry-react-native/pull/4423)) - Add RN SDK package to `sdk.packages` for Cocoa ([#4381](https://github.com/getsentry/sentry-react-native/pull/4381)) - Add experimental version of `startWithConfigureOptions` for Apple platforms ([#4444](https://github.com/getsentry/sentry-react-native/pull/4444)) +- Add experimental version of `init` with optional `OptionsConfiguration` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) - Add initialization using `sentry.options.json` for Apple platforms ([#4447](https://github.com/getsentry/sentry-react-native/pull/4447)) +- Add initialization using `sentry.options.json` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) ### Internal diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json new file mode 100644 index 0000000000..be3bb71111 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json @@ -0,0 +1,3 @@ +{ + "dsn": "invalid-dsn" +} \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt new file mode 100644 index 0000000000..f07bfaea41 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt @@ -0,0 +1 @@ +invalid-options \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json new file mode 100644 index 0000000000..f97a8df3f2 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json @@ -0,0 +1,5 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableTracing": true, + "tracesSampleRate": 1.0 +} \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt new file mode 100644 index 0000000000..3b95742e55 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt @@ -0,0 +1,200 @@ +package io.sentry.react + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.common.JavascriptException +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.Sentry +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.SentryEvent +import io.sentry.android.core.AndroidLogger +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.protocol.SdkVersion +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RNSentrySDKTest { + private val logger: ILogger = AndroidLogger(RNSentrySDKTest::class.java.simpleName) + private lateinit var context: Context + + companion object { + private const val INITIALISATION_ERROR = "Failed to initialize Sentry's React Native SDK" + private const val VALID_OPTIONS = "sentry.options.json" + private const val INVALID_OPTIONS = "invalid.options.json" + private const val INVALID_JSON = "invalid.options.txt" + private const val MISSING = "non-existing-file" + + private val validConfig = + OptionsConfiguration { options -> + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + private val invalidConfig = + OptionsConfiguration { options -> + options.dsn = "invalid-dsn" + } + private val emptyConfig = OptionsConfiguration {} + } + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + } + + @After + fun tearDown() { + Sentry.close() + } + + @Test + fun initialisesSuccessfullyWithDefaultValidJsonFile() { // sentry.options.json + RNSentrySDK.init(context) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndDefaultValidJsonFile() { + RNSentrySDK.init(context, validConfig) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndInvalidJsonFile() { + RNSentrySDK.init(context, validConfig, INVALID_OPTIONS, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndMissingJsonFile() { + RNSentrySDK.init(context, validConfig, MISSING, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndErrorInParsingJsonFile() { + RNSentrySDK.init(context, validConfig, INVALID_JSON, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithNoConfigurationAndValidJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithNoConfigurationAndInvalidJsonFile() { + try { + RNSentrySDK.init(context, emptyConfig, INVALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigAndInvalidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig, INVALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigAndValidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig, VALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigurationAndDefaultValidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun defaultsAndFinalsAreSetWithValidJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + verifyDefaults(actualOptions) + verifyFinals(actualOptions) + // options file + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + @Test + fun defaultsAndFinalsAreSetWithValidConfiguration() { + RNSentrySDK.init(context, validConfig, MISSING, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + verifyDefaults(actualOptions) + verifyFinals(actualOptions) + // configuration + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + @Test + fun defaultsOverrideOptionsJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + assertNull(actualOptions.tracesSampleRate) + assertEquals(false, actualOptions.enableTracing) + } + + @Test + fun configurationOverridesDefaultOptions() { + val validConfig = + OptionsConfiguration { options -> + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.tracesSampleRate = 0.5 + options.enableTracing = true + } + RNSentrySDK.init(context, validConfig, MISSING, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + assertEquals(0.5, actualOptions.tracesSampleRate) + assertEquals(true, actualOptions.enableTracing) + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + private fun verifyDefaults(actualOptions: SentryAndroidOptions) { + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) + assertEquals( + io.sentry.android.core.BuildConfig.VERSION_NAME, + actualOptions.sdkVersion?.version, + ) + val pack = actualOptions.sdkVersion?.packages?.first { it.name == RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME } + assertNotNull(pack) + assertEquals(RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION, pack?.version) + assertNull(actualOptions.tracesSampleRate) + assertNull(actualOptions.tracesSampler) + assertEquals(false, actualOptions.enableTracing) + } + + private fun verifyFinals(actualOptions: SentryAndroidOptions) { + val event = + SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + val result = actualOptions.beforeSend?.execute(event, Hint()) + assertNotNull(result) + assertEquals("android", result?.getTag("event.origin")) + assertEquals("java", result?.getTag("event.environment")) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt new file mode 100644 index 0000000000..699fd81ccb --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.react + +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.android.core.SentryAndroidOptions +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(JUnit4::class) +class RNSentryCompositeOptionsConfigurationTest { + @Test + fun `configure should call base and overriding configurations`() { + val baseConfig: OptionsConfiguration = mock() + val overridingConfig: OptionsConfiguration = mock() + + val compositeConfig = RNSentryCompositeOptionsConfiguration(baseConfig, overridingConfig) + val options = SentryAndroidOptions() + compositeConfig.configure(options) + + verify(baseConfig).configure(options) + verify(overridingConfig).configure(options) + } + + @Test + fun `configure should apply base configuration and override values`() { + val baseConfig = + OptionsConfiguration { options -> + options.dsn = "https://base-dsn@sentry.io" + options.isDebug = false + options.release = "some-release" + } + val overridingConfig = + OptionsConfiguration { options -> + options.dsn = "https://over-dsn@sentry.io" + options.isDebug = true + options.environment = "production" + } + + val compositeConfig = RNSentryCompositeOptionsConfiguration(baseConfig, overridingConfig) + val options = SentryAndroidOptions() + compositeConfig.configure(options) + + assert(options.dsn == "https://over-dsn@sentry.io") // overridden value + assert(options.isDebug) // overridden value + assert(options.release == "some-release") // base value not overridden + assert(options.environment == "production") // overridden value not in base + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt index c2ee6f1d88..fa177159e5 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt @@ -5,9 +5,13 @@ import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.common.JavascriptException import io.sentry.Breadcrumb import io.sentry.ILogger +import io.sentry.SentryEvent +import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions +import io.sentry.protocol.SdkVersion import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -40,7 +44,7 @@ class RNSentryStartTest { "http://localhost:8969/teststream", ) val actualOptions = SentryAndroidOptions() - RNSentryStart.getSentryAndroidOptions(actualOptions, options, activity, logger) + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) assert(actualOptions.isEnableSpotlight) assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) } @@ -49,7 +53,7 @@ class RNSentryStartTest { fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") val actualOptions = SentryAndroidOptions() - RNSentryStart.getSentryAndroidOptions(actualOptions, options, activity, logger) + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) assert(actualOptions.isEnableSpotlight) assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) } @@ -58,17 +62,10 @@ class RNSentryStartTest { fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { val options = JavaOnlyMap.of("spotlight", false) val actualOptions = SentryAndroidOptions() - RNSentryStart.getSentryAndroidOptions(actualOptions, options, activity, logger) + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) assertFalse(actualOptions.isEnableSpotlight) } - @Test - fun `the JavascriptException is added to the ignoredExceptionsForType list on initialisation`() { - val actualOptions = SentryAndroidOptions() - RNSentryStart.getSentryAndroidOptions(actualOptions, JavaOnlyMap.of(), activity, logger) - assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) - } - @Test fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { val options = SentryAndroidOptions() @@ -79,7 +76,7 @@ class RNSentryStartTest { "devServerUrl", "http://localhost:8081", ) - RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) val breadcrumb = Breadcrumb().apply { @@ -103,7 +100,7 @@ class RNSentryStartTest { "devServerUrl", mockDevServerUrl, ) - RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) val breadcrumb = Breadcrumb().apply { @@ -126,7 +123,7 @@ class RNSentryStartTest { "devServerUrl", "http://localhost:8081", ) - RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) val breadcrumb = Breadcrumb().apply { @@ -142,7 +139,7 @@ class RNSentryStartTest { @Test fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { val options = SentryAndroidOptions() - RNSentryStart.getSentryAndroidOptions(options, JavaOnlyMap(), activity, logger) + RNSentryStart.getSentryAndroidOptions(options, JavaOnlyMap(), logger) val breadcrumb = Breadcrumb().apply { @@ -159,7 +156,7 @@ class RNSentryStartTest { fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { val options = SentryAndroidOptions() val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") - RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) val breadcrumb = Breadcrumb().apply { @@ -176,7 +173,7 @@ class RNSentryStartTest { fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { val options = SentryAndroidOptions() val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") - RNSentryStart.getSentryAndroidOptions(options, rnOptions, activity, logger) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) val breadcrumb = Breadcrumb().apply { @@ -188,4 +185,67 @@ class RNSentryStartTest { assertEquals(breadcrumb, result) } + + @Test + fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + } + + @Test + fun `the sdk version information is added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) + assertEquals( + io.sentry.android.core.BuildConfig.VERSION_NAME, + actualOptions.sdkVersion?.version, + ) + assertEquals(true, actualOptions.sdkVersion?.packages?.isNotEmpty()) + assertEquals( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + actualOptions.sdkVersion + ?.packages + ?.last() + ?.name, + ) + assertEquals( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION, + actualOptions.sdkVersion + ?.packages + ?.last() + ?.version, + ) + } + + @Test + fun `the tracing options are added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertNull(actualOptions.tracesSampleRate) + assertNull(actualOptions.tracesSampler) + assertEquals(false, actualOptions.enableTracing) + } + + @Test + fun `the current activity is added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertEquals(activity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `beforeSend callback that sets event tags is set with react finals`() { + val options = SentryAndroidOptions() + val event = + SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + + RNSentryStart.updateWithReactFinals(options) + val result = options.beforeSend?.execute(event, mock()) + + assertNotNull(result) + assertEquals("android", result?.getTag("event.origin")) + assertEquals("java", result?.getTag("event.environment")) + } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java new file mode 100644 index 0000000000..0069abb660 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java @@ -0,0 +1,25 @@ +package io.sentry.react; + +import io.sentry.Sentry.OptionsConfiguration; +import io.sentry.android.core.SentryAndroidOptions; +import java.util.List; +import org.jetbrains.annotations.NotNull; + +class RNSentryCompositeOptionsConfiguration implements OptionsConfiguration { + private final @NotNull List> configurations; + + @SafeVarargs + protected RNSentryCompositeOptionsConfiguration( + @NotNull OptionsConfiguration... configurations) { + this.configurations = List.of(configurations); + } + + @Override + public void configure(@NotNull SentryAndroidOptions options) { + for (OptionsConfiguration configuration : configurations) { + if (configuration != null) { + configuration.configure(options); + } + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java new file mode 100644 index 0000000000..9c7cf5d3ff --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java @@ -0,0 +1,41 @@ +package io.sentry.react; + +import android.content.Context; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; + +final class RNSentryJsonUtils { + private RNSentryJsonUtils() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static @Nullable JSONObject getOptionsFromConfigurationFile( + @NotNull Context context, @NotNull String fileName, @NotNull ILogger logger) { + try (InputStream inputStream = context.getAssets().open(fileName); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + String configFileContent = stringBuilder.toString(); + return new JSONObject(configFileContent); + + } catch (Exception e) { + logger.log( + SentryLevel.ERROR, + "Failed to read configuration file. Please make sure " + + fileName + + " exists in the root of your project.", + e); + return null; + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java b/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java new file mode 100644 index 0000000000..ca219351fe --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java @@ -0,0 +1,68 @@ +package io.sentry.react; + +import android.content.Context; +import com.facebook.react.bridge.ReadableMap; +import io.sentry.ILogger; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.SentryAndroidOptions; +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; + +public final class RNSentrySDK { + private static final String CONFIGURATION_FILE = "sentry.options.json"; + private static final String NAME = "RNSentrySDK"; + + private static final ILogger logger = new AndroidLogger(NAME); + + private RNSentrySDK() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static void init( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration, + @NotNull String configurationFile, + @NotNull ILogger logger) { + try { + JSONObject jsonObject = + RNSentryJsonUtils.getOptionsFromConfigurationFile(context, configurationFile, logger); + if (jsonObject == null) { + RNSentryStart.startWithConfiguration(context, configuration); + return; + } + ReadableMap rnOptions = RNSentryJsonConverter.convertToWritable(jsonObject); + if (rnOptions == null) { + RNSentryStart.startWithConfiguration(context, configuration); + return; + } + RNSentryStart.startWithOptions(context, rnOptions, configuration, logger); + } catch (Exception e) { + logger.log( + SentryLevel.ERROR, "Failed to start Sentry with options from configuration file.", e); + throw new RuntimeException("Failed to initialize Sentry's React Native SDK", e); + } + } + + /** + * @experimental Start the Native Android SDK with the provided configuration options. Uses as a + * base configurations the `sentry.options.json` configuration file if it exists. + * @param context Android Context + * @param configuration configuration options + */ + public static void init( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration) { + init(context, configuration, CONFIGURATION_FILE, logger); + } + + /** + * @experimental Start the Native Android SDK with options from `sentry.options.json` + * configuration file. + * @param context Android Context + */ + public static void init(@NotNull final Context context) { + init(context, options -> {}, CONFIGURATION_FILE, logger); + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index 263633c4a8..86699ced05 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -7,8 +7,10 @@ import com.facebook.react.common.JavascriptException; import io.sentry.ILogger; import io.sentry.Integration; +import io.sentry.Sentry; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryOptions.BeforeSendCallback; import io.sentry.SentryReplayOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AnrIntegration; @@ -27,40 +29,57 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class RNSentryStart { +final class RNSentryStart { private RNSentryStart() { throw new AssertionError("Utility class should not be instantiated"); } - public static void startWithOptions( + static void startWithConfiguration( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, null); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + defaults, configuration, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void startWithOptions( + @NotNull final Context context, + @NotNull final ReadableMap rnOptions, + @NotNull Sentry.OptionsConfiguration configuration, + @NotNull ILogger logger) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, null); + Sentry.OptionsConfiguration rnConfigurationOptions = + options -> getSentryAndroidOptions(options, rnOptions, logger); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + rnConfigurationOptions, defaults, configuration, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void startWithOptions( @NotNull final Context context, @NotNull final ReadableMap rnOptions, @Nullable Activity currentActivity, @NotNull ILogger logger) { - SentryAndroid.init( - context, options -> getSentryAndroidOptions(options, rnOptions, currentActivity, logger)); + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, currentActivity); + Sentry.OptionsConfiguration rnConfigurationOptions = + options -> getSentryAndroidOptions(options, rnOptions, logger); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + rnConfigurationOptions, defaults, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); } static void getSentryAndroidOptions( @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, - @Nullable Activity currentActivity, - ILogger logger) { - @Nullable SdkVersion sdkVersion = options.getSdkVersion(); - if (sdkVersion == null) { - sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); - } else { - sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); - } - sdkVersion.addPackage( - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - - options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); - options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); - + @NotNull ILogger logger) { if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { options.setDebug(true); } @@ -159,18 +178,6 @@ static void getSentryAndroidOptions( return breadcrumb; }); - // React native internally throws a JavascriptException. - // we want to ignore it on the native side to avoid sending it twice. - options.addIgnoredExceptionForType(JavascriptException.class); - - options.setBeforeSend( - (event, hint) -> { - setEventOriginTag(event); - addPackages(event, options.getSdkVersion()); - - return event; - }); - if (rnOptions.hasKey("enableNativeCrashHandling") && !rnOptions.getBoolean("enableNativeCrashHandling")) { final List integrations = options.getIntegrations(); @@ -184,10 +191,57 @@ static void getSentryAndroidOptions( } logger.log( SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); + } + + /** + * This function updates the options with RNSentry defaults. These default can be overwritten by + * users during manual native initialization. + */ + static void updateWithReactDefaults( + @NotNull SentryAndroidOptions options, @Nullable Activity currentActivity) { + @Nullable SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion == null) { + sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); + } else { + sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); + } + sdkVersion.addPackage( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + + options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); + options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); + options.setSdkVersion(sdkVersion); + + // Tracing is only enabled in JS to avoid duplicate navigation spans + options.setTracesSampleRate(null); + options.setTracesSampler(null); + options.setEnableTracing(false); + + // React native internally throws a JavascriptException. + // we want to ignore it on the native side to avoid sending it twice. + options.addIgnoredExceptionForType(JavascriptException.class); setCurrentActivity(currentActivity); } + /** + * This function updates options with changes RNSentry users should not change and so this is + * applied after the configureOptions callback during manual native initialization. + */ + static void updateWithReactFinals(@NotNull SentryAndroidOptions options) { + BeforeSendCallback userBeforeSend = options.getBeforeSend(); + options.setBeforeSend( + (event, hint) -> { + setEventOriginTag(event); + addPackages(event, options.getSdkVersion()); + if (userBeforeSend != null) { + return userBeforeSend.execute(event, hint); + } + return event; + }); + } + private static void setCurrentActivity(Activity currentActivity) { final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); if (currentActivity != null) { diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle index fbbf567412..990703527f 100644 --- a/packages/core/sentry.gradle +++ b/packages/core/sentry.gradle @@ -3,7 +3,7 @@ import org.apache.tools.ant.taskdefs.condition.Os import java.util.regex.Matcher import java.util.regex.Pattern -project.ext.shouldSentryAutoUploadNative = { -> +project.ext.shouldSentryAutoUploadNative = { -> return System.getenv('SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD') != 'true' } @@ -15,9 +15,60 @@ project.ext.shouldSentryAutoUpload = { -> return shouldSentryAutoUploadGeneral() && shouldSentryAutoUploadNative() } +project.ext.shouldCopySentryOptionsFile = { -> // If not set, default to true + return System.getenv('SENTRY_COPY_OPTIONS_FILE') != 'false' +} + def config = project.hasProperty("sentryCli") ? project.sentryCli : []; +def configFile = "sentry.options.json" // Sentry condiguration file +def androidAssetsDir = new File("$rootDir/app/src/main/assets") // Path to Android assets folder + +tasks.register("copySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + def appRoot = project.rootDir.parentFile ?: project.rootDir + def sentryOptionsFile = new File(appRoot, configFile) + if (sentryOptionsFile.exists()) { + if (!androidAssetsDir.exists()) { + androidAssetsDir.mkdirs() + } + + copy { + from sentryOptionsFile + into androidAssetsDir + rename { String fileName -> configFile } + } + logger.lifecycle("Copied ${configFile} to Android assets") + } else { + logger.warn("${configFile} not found in app root (${appRoot})") + } + } +} + +tasks.register("cleanupTemporarySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + def sentryOptionsFile = new File(androidAssetsDir, configFile) + if (sentryOptionsFile.exists()) { + logger.lifecycle("Deleting temporary file: ${sentryOptionsFile.path}") + sentryOptionsFile.delete() + } + } +} + gradle.projectsEvaluated { + // Add a task that copies the sentry.options.json file before the build starts + tasks.named("preBuild").configure { + dependsOn("copySentryJsonConfiguration") + } + // Cleanup sentry.options.json from assets after the build + tasks.matching { task -> + task.name == "build" || task.name.startsWith("assemble") || task.name.startsWith("install") + }.configureEach { + finalizedBy("cleanupTemporarySentryJsonConfiguration") + } + def releases = extractReleasesInfo() if (config.flavorAware && config.sentryProperties) { diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt index 07747f085c..6546ca8b10 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt @@ -11,10 +11,7 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.soloader.SoLoader -import io.sentry.Hint -import io.sentry.SentryEvent -import io.sentry.SentryOptions.BeforeSendCallback -import io.sentry.android.core.SentryAndroid +import io.sentry.react.RNSentrySDK class MainApplication : Application(), @@ -51,28 +48,12 @@ class MainApplication : } private fun initializeSentry() { - SentryAndroid.init(this) { options -> - // Only options set here will apply to the Android SDK - // Options from JS are not passed to the Android SDK when initialized manually + RNSentrySDK.init(this) { options -> + // Options set here will apply to the Android SDK overriding the ones from `sentry.options.json` options.dsn = "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561" options.isDebug = true - - options.beforeSend = - BeforeSendCallback { event: SentryEvent, hint: Hint? -> - // React native internally throws a JavascriptException - // Since we catch it before that, we don't want to send this one - // because we would send it twice - try { - val ex = event.exceptions!![0] - if (null != ex && ex.type!!.contains("JavascriptException")) { - return@BeforeSendCallback null - } - } catch (ignored: Throwable) { - // We do nothing - } - - event - } } + + // RNSentrySDK.init(this) } } From 14fe05db7ca4c5e24a383623aa06db2f45fdf546 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:15:08 +0100 Subject: [PATCH 08/21] misc: Add `sentry.options.json` example to the changelog (#4509) --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8208dd1e92..51248fd473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,44 @@ ## Unreleased +### Features + +- Capture App Start errors and crashes by initializing Sentry from `sentry.options.json` ([#4472](https://github.com/getsentry/sentry-react-native/pull/4472)) + + Create `sentry.options.json` in the React Native project root and set options the same as you currently have in `Sentry.init` in JS. + + ```json + { + "dsn": "https://key@example.io/value", + } + ``` + + Initialize Sentry on the native layers by newly provided native methods. + + ```kotlin + import io.sentry.react.RNSentrySDK + + class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + RNSentrySDK.init(this) + } + } + ``` + + ```obj-c + #import + + @implementation AppDelegate + - (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions + { + [RNSentrySDK start]; + return [super application:application didFinishLaunchingWithOptions:launchOptions]; + } + @end + ``` + ### Changes - Load `optionsFile` into the JS bundle during Metro bundle process ([#4476](https://github.com/getsentry/sentry-react-native/pull/4476)) From 15a7e6d7d7c6d144a3471cc37d6b7b68e48efb57 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:48:30 +0100 Subject: [PATCH 09/21] feat(init): Load options from `sentry.options.json` in JS (#4510) --- CHANGELOG.md | 1 + packages/core/src/js/sdk.tsx | 46 ++++++++++++------ packages/core/src/js/utils/worldwide.ts | 2 + packages/core/test/sdk.test.ts | 64 ++++++++++++++++++++++--- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51248fd473..dbd1e20772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - Add experimental version of `init` with optional `OptionsConfiguration` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) - Add initialization using `sentry.options.json` for Apple platforms ([#4447](https://github.com/getsentry/sentry-react-native/pull/4447)) - Add initialization using `sentry.options.json` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) +- Merge options from file with `Sentry.init` options in JS ([#4510](https://github.com/getsentry/sentry-react-native/pull/4510)) ### Internal diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 3c6fdff90c..3ff40508af 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -19,6 +19,7 @@ import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer } from './utils/environment'; import { safeFactory, safeTracesSampler } from './utils/safe'; +import { RN_GLOBAL_OBJ } from './utils/worldwide'; import { NATIVE } from './wrapper'; const DEFAULT_OPTIONS: ReactNativeOptions = { @@ -47,12 +48,17 @@ export function init(passedOptions: ReactNativeOptions): void { return; } - const maxQueueSize = passedOptions.maxQueueSize + const userOptions = { + ...RN_GLOBAL_OBJ.__SENTRY_OPTIONS__, + ...passedOptions, + }; + + const maxQueueSize = userOptions.maxQueueSize // eslint-disable-next-line deprecation/deprecation - ?? passedOptions.transportOptions?.bufferSize + ?? userOptions.transportOptions?.bufferSize ?? DEFAULT_OPTIONS.maxQueueSize; - const enableNative = passedOptions.enableNative === undefined || passedOptions.enableNative + const enableNative = userOptions.enableNative === undefined || userOptions.enableNative ? NATIVE.isNativeAvailable() : false; @@ -75,11 +81,11 @@ export function init(passedOptions: ReactNativeOptions): void { return `${dsnComponents.protocol}://${dsnComponents.host}${port}`; }; - const userBeforeBreadcrumb = safeFactory(passedOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }); + const userBeforeBreadcrumb = safeFactory(userOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }); // Exclude Dev Server and Sentry Dsn request from Breadcrumbs const devServerUrl = getDevServer()?.url; - const dsn = getURLFromDSN(passedOptions.dsn); + const dsn = getURLFromDSN(userOptions.dsn); const defaultBeforeBreadcrumb = (breadcrumb: Breadcrumb, _hint?: BreadcrumbHint): Breadcrumb | null => { const type = breadcrumb.type || ''; const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : ''; @@ -103,26 +109,34 @@ export function init(passedOptions: ReactNativeOptions): void { const options: ReactNativeClientOptions = { ...DEFAULT_OPTIONS, - ...passedOptions, + ...userOptions, enableNative, - enableNativeNagger: shouldEnableNativeNagger(passedOptions.enableNativeNagger), + enableNativeNagger: shouldEnableNativeNagger(userOptions.enableNativeNagger), // If custom transport factory fails the SDK won't initialize - transport: passedOptions.transport + transport: userOptions.transport || makeNativeTransportFactory({ enableNative, }) || makeFetchTransport, transportOptions: { ...DEFAULT_OPTIONS.transportOptions, - ...(passedOptions.transportOptions ?? {}), + ...(userOptions.transportOptions ?? {}), bufferSize: maxQueueSize, }, maxQueueSize, integrations: [], - stackParser: stackParserFromStackParserOptions(passedOptions.stackParser || defaultStackParser), + stackParser: stackParserFromStackParserOptions(userOptions.stackParser || defaultStackParser), beforeBreadcrumb: chainedBeforeBreadcrumb, - initialScope: safeFactory(passedOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }), + initialScope: safeFactory(userOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }), }; + + if (!('autoInitializeNativeSdk' in userOptions) && RN_GLOBAL_OBJ.__SENTRY_OPTIONS__) { + // We expect users to use the file options only in combination with manual native initialization + // eslint-disable-next-line no-console + console.info('Initializing Sentry JS with the options file. Expecting manual native initialization before JS. Native will not be initialized automatically.'); + options.autoInitializeNativeSdk = false; + } + if ('tracesSampler' in options) { options.tracesSampler = safeTracesSampler(options.tracesSampler); } @@ -131,12 +145,12 @@ export function init(passedOptions: ReactNativeOptions): void { options.environment = getDefaultEnvironment(); } - const defaultIntegrations: false | Integration[] = passedOptions.defaultIntegrations === undefined + const defaultIntegrations: false | Integration[] = userOptions.defaultIntegrations === undefined ? getDefaultIntegrations(options) - : passedOptions.defaultIntegrations; + : userOptions.defaultIntegrations; options.integrations = getIntegrationsToSetup({ - integrations: safeFactory(passedOptions.integrations, { loggerMessage: 'The integrations threw an error' }), + integrations: safeFactory(userOptions.integrations, { loggerMessage: 'The integrations threw an error' }), defaultIntegrations, }); initAndBind(ReactNativeClient, options); @@ -145,6 +159,10 @@ export function init(passedOptions: ReactNativeOptions): void { logger.info('Offline caching, native errors features are not available in Expo Go.'); logger.info('Use EAS Build / Native Release Build to test these features.'); } + + if (RN_GLOBAL_OBJ.__SENTRY_OPTIONS__) { + logger.info('Sentry JS initialized with options from the options file.'); + } } /** diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index c1a4ae5dbb..0dc265763d 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -2,6 +2,7 @@ import type { InternalGlobal } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import type { ErrorUtils } from 'react-native/types'; +import type { ReactNativeOptions } from '../options'; import type { ExpoGlobalObject } from './expoglobalobject'; /** Internal Global object interface with common and Sentry specific properties */ @@ -25,6 +26,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { __BUNDLE_START_TIME__?: number; nativePerformanceNow?: () => number; TextEncoder?: TextEncoder; + __SENTRY_OPTIONS__?: ReactNativeOptions; } type TextEncoder = { diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index afd6137c8a..0e64264899 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -1,13 +1,15 @@ -import type { BaseTransportOptions, Breadcrumb, BreadcrumbHint, ClientOptions, Integration, Scope } from '@sentry/core'; +import type { Breadcrumb, BreadcrumbHint, Integration, Scope } from '@sentry/core'; import { initAndBind, logger } from '@sentry/core'; import { makeFetchTransport } from '@sentry/react'; import { getDevServer } from '../src/js/integrations/debugsymbolicatorutils'; +import type { ReactNativeClientOptions } from '../src/js/options'; import { init, withScope } from '../src/js/sdk'; import type { ReactNativeTracingIntegration } from '../src/js/tracing'; import { REACT_NATIVE_TRACING_INTEGRATION_NAME, reactNativeTracingIntegration } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; +import { RN_GLOBAL_OBJ } from '../src/js/utils/worldwide'; import { NATIVE } from './mockWrapper'; import { firstArg, secondArg } from './testutils'; @@ -109,6 +111,60 @@ describe('Tests the SDK functionality', () => { }); }); + describe('initialization from sentry.options.json', () => { + it('initializes without __SENTRY_OPTIONS__', () => { + delete RN_GLOBAL_OBJ.__SENTRY_OPTIONS__; + init({}); + expect(initAndBind).toHaveBeenCalledOnce(); + }); + + it('adds options from __SENTRY_OPTIONS__', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = { + dsn: 'https://key@example.io/value', + }; + init({}); + expect(usedOptions()?.dsn).toBe('https://key@example.io/value'); + }); + + it('options init override options from __SENTRY_OPTIONS__', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = { + dsn: 'https://key@example.io/file', + }; + init({ + dsn: 'https://key@example.io/code', + }); + expect(usedOptions()?.dsn).toBe('https://key@example.io/code'); + }); + + it('initializing with __SENTRY_OPTIONS__ disabled native auto initialization', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({}); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(false); + }); + + it('initializing without __SENTRY_OPTIONS__ enables native auto initialization', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = undefined; + init({}); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(true); + }); + + it('initializing with __SENTRY_OPTIONS__ keeps native auto initialization true if set', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({ + autoInitializeNativeSdk: true, + }); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(true); + }); + + it('initializing with __SENTRY_OPTIONS__ keeps native auto initialization false if set', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({ + autoInitializeNativeSdk: false, + }); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(false); + }); + }); + describe('environment', () => { it('detect development environment', () => { (getDefaultEnvironment as jest.Mock).mockImplementation(() => 'development'); @@ -173,7 +229,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({}); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -182,7 +237,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({ enableNative: true }); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -191,7 +245,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({ enableNative: false }); expect(NATIVE.isNativeAvailable).not.toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -204,7 +257,6 @@ describe('Tests the SDK functionality', () => { }); expect(usedOptions()?.transport).toEqual(mockTransport); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); }); }); @@ -1058,7 +1110,7 @@ function createMockedIntegration({ name }: { name?: string } = {}): Integration }; } -function usedOptions(): ClientOptions | undefined { +function usedOptions(): ReactNativeClientOptions | undefined { return (initAndBind as jest.MockedFunction).mock.calls[0]?.[secondArg]; } From b9ec093320cd286a1916ab1795e20f31d1a2f8ce Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 6 Feb 2025 14:02:16 +0000 Subject: [PATCH 10/21] release: 6.7.0-alpha.0 --- CHANGELOG.md | 2 +- dev-packages/e2e-tests/package.json | 4 ++-- dev-packages/type-check/package.json | 2 +- dev-packages/utils/package.json | 2 +- lerna.json | 2 +- .../main/java/io/sentry/react/RNSentryVersion.java | 2 +- packages/core/ios/RNSentryVersion.m | 2 +- packages/core/package.json | 2 +- packages/core/src/js/version.ts | 2 +- performance-tests/TestAppPlain/package.json | 2 +- performance-tests/TestAppSentry/package.json | 4 ++-- samples/expo/app.json | 6 +++--- samples/expo/package.json | 4 ++-- samples/react-native-macos/package.json | 4 ++-- samples/react-native/android/app/build.gradle | 4 ++-- .../ios/sentryreactnativesample/Info.plist | 4 ++-- .../ios/sentryreactnativesampleTests/Info.plist | 4 ++-- samples/react-native/package.json | 4 ++-- yarn.lock | 12 ++++++------ 19 files changed, 34 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd1e20772..bb8c861c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. -## Unreleased +## 6.7.0-alpha.0 ### Features diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index fd1a5e25c7..6b2c4e305c 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-e2e-tests", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "description": "Sentry React Native End to End Tests Library", "main": "dist/index.js", @@ -14,7 +14,7 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-typescript": "^7.18.6", "@sentry/core": "8.54.0", - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "@types/node": "^20.9.3", "@types/react": "^18.2.64", "appium": "2.4.1", diff --git a/dev-packages/type-check/package.json b/dev-packages/type-check/package.json index 6ed44c18a9..01cbef78bb 100644 --- a/dev-packages/type-check/package.json +++ b/dev-packages/type-check/package.json @@ -1,7 +1,7 @@ { "name": "sentry-react-native-type-check", "private": true, - "version": "6.6.0", + "version": "6.7.0-alpha.0", "scripts": { "type-check": "./run-type-check.sh" } diff --git a/dev-packages/utils/package.json b/dev-packages/utils/package.json index dc690d20e3..3eab86ea36 100644 --- a/dev-packages/utils/package.json +++ b/dev-packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-samples-utils", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "description": "Internal Samples Utils", "main": "index.js", "license": "MIT", diff --git a/lerna.json b/lerna.json index 44a86ffc11..bdcc106f79 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "packages": [ "packages/*", "dev-packages/*", diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java index 23b1b258ed..b9a3d71bb7 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java @@ -2,7 +2,7 @@ class RNSentryVersion { static final String REACT_NATIVE_SDK_PACKAGE_NAME = "npm:@sentry/react-native"; - static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.6.0"; + static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.7.0-alpha.0"; static final String NATIVE_SDK_NAME = "sentry.native.android.react-native"; static final String ANDROID_SDK_NAME = "sentry.java.android.react-native"; static final String REACT_NATIVE_SDK_NAME = "sentry.javascript.react-native"; diff --git a/packages/core/ios/RNSentryVersion.m b/packages/core/ios/RNSentryVersion.m index 5bdb2cbbc3..063c8ee257 100644 --- a/packages/core/ios/RNSentryVersion.m +++ b/packages/core/ios/RNSentryVersion.m @@ -3,4 +3,4 @@ NSString *const NATIVE_SDK_NAME = @"sentry.cocoa.react-native"; NSString *const REACT_NATIVE_SDK_NAME = @"sentry.javascript.react-native"; NSString *const REACT_NATIVE_SDK_PACKAGE_NAME = @"npm:@sentry/react-native"; -NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.6.0"; +NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.7.0-alpha.0"; diff --git a/packages/core/package.json b/packages/core/package.json index 82f939e104..0370e4c183 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@sentry/react-native", "homepage": "https://github.com/getsentry/sentry-react-native", "repository": "https://github.com/getsentry/sentry-react-native", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "description": "Official Sentry SDK for react-native", "typings": "dist/js/index.d.ts", "types": "dist/js/index.d.ts", diff --git a/packages/core/src/js/version.ts b/packages/core/src/js/version.ts index 6718af2331..9714e3938e 100644 --- a/packages/core/src/js/version.ts +++ b/packages/core/src/js/version.ts @@ -1,3 +1,3 @@ export const SDK_PACKAGE_NAME = 'npm:@sentry/react-native'; export const SDK_NAME = 'sentry.javascript.react-native'; -export const SDK_VERSION = '6.6.0'; +export const SDK_VERSION = '6.7.0-alpha.0'; diff --git a/performance-tests/TestAppPlain/package.json b/performance-tests/TestAppPlain/package.json index 67b6a8b3dd..74a009292d 100644 --- a/performance-tests/TestAppPlain/package.json +++ b/performance-tests/TestAppPlain/package.json @@ -1,6 +1,6 @@ { "name": "TestAppPlain", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "android": "react-native run-android", diff --git a/performance-tests/TestAppSentry/package.json b/performance-tests/TestAppSentry/package.json index de301f4bf2..7e8f774c2a 100644 --- a/performance-tests/TestAppSentry/package.json +++ b/performance-tests/TestAppSentry/package.json @@ -1,6 +1,6 @@ { "name": "TestAppSentry", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "android": "react-native run-android", @@ -8,7 +8,7 @@ "start": "react-native start" }, "dependencies": { - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "react": "18.1.0", "react-native": "0.70.15" }, diff --git a/samples/expo/app.json b/samples/expo/app.json index c97346d0b5..a9251abe0a 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -4,7 +4,7 @@ "slug": "sentry-react-native-expo-sample", "jsEngine": "hermes", "scheme": "sentry-expo-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -19,7 +19,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", - "buildNumber": "36" + "buildNumber": "37" }, "android": { "adaptiveIcon": { @@ -27,7 +27,7 @@ "backgroundColor": "#ffffff" }, "package": "io.sentry.expo.sample", - "versionCode": 36 + "versionCode": 37 }, "web": { "bundler": "metro", diff --git a/samples/expo/package.json b/samples/expo/package.json index b151813e59..bf2fcbc1d5 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-expo-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -16,7 +16,7 @@ "set-version": "npx react-native-version --skip-tag --never-amend" }, "dependencies": { - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "@types/react": "~18.3.12", "expo": "^52.0.0", "expo-constants": "~17.0.3", diff --git a/samples/react-native-macos/package.json b/samples/react-native-macos/package.json index 47d5e98ced..2fa48cf7cc 100644 --- a/samples/react-native-macos/package.json +++ b/samples/react-native-macos/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-macos-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "start": "react-native start --experimental-debugger", @@ -18,7 +18,7 @@ "@react-navigation/stack": "^6.3.20", "@sentry/core": "8.54.0", "@sentry/react": "8.54.0", - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "delay": "^6.0.0", "react": "18.2.0", "react-native": "0.73.9", diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index 4c1341b4c8..2d24b2ede1 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -136,8 +136,8 @@ android { applicationId "io.sentry.reactnative.sample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 38 - versionName "6.6.0" + versionCode 39 + versionName "6.7.0-alpha.0" } signingConfigs { diff --git a/samples/react-native/ios/sentryreactnativesample/Info.plist b/samples/react-native/ios/sentryreactnativesample/Info.plist index 12ed645a84..17529727b2 100644 --- a/samples/react-native/ios/sentryreactnativesample/Info.plist +++ b/samples/react-native/ios/sentryreactnativesample/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 6.6.0 + 6.7.0 CFBundleSignature ???? CFBundleVersion - 43 + 44 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist index 515e2c9f2a..363ef122d5 100644 --- a/samples/react-native/ios/sentryreactnativesampleTests/Info.plist +++ b/samples/react-native/ios/sentryreactnativesampleTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 6.6.0 + 6.7.0 CFBundleSignature ???? CFBundleVersion - 43 + 44 diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 02d2f942dd..143a4b6c7f 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-sample", - "version": "6.6.0", + "version": "6.7.0-alpha.0", "private": true, "scripts": { "postinstall": "patch-package", @@ -25,7 +25,7 @@ "@react-navigation/native": "^7.0.3", "@react-navigation/native-stack": "^7.0.3", "@react-navigation/stack": "^7.0.3", - "@sentry/react-native": "6.6.0", + "@sentry/react-native": "6.7.0-alpha.0", "delay": "^6.0.0", "react": "18.3.1", "react-native": "0.76.3", diff --git a/yarn.lock b/yarn.lock index 6e3e8b6a99..64b67a8bb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7805,7 +7805,7 @@ __metadata: languageName: node linkType: hard -"@sentry/react-native@6.6.0, @sentry/react-native@workspace:packages/core": +"@sentry/react-native@6.7.0-alpha.0, @sentry/react-native@workspace:packages/core": version: 0.0.0-use.local resolution: "@sentry/react-native@workspace:packages/core" dependencies: @@ -9480,7 +9480,7 @@ __metadata: dependencies: "@babel/core": ^7.12.9 "@babel/runtime": ^7.12.5 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 metro-react-native-babel-preset: ^0.72.3 react: 18.1.0 react-native: 0.70.15 @@ -24156,7 +24156,7 @@ __metadata: "@babel/preset-env": ^7.25.3 "@babel/preset-typescript": ^7.18.6 "@sentry/core": 8.54.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/node": ^20.9.3 "@types/react": ^18.2.64 appium: 2.4.1 @@ -24185,7 +24185,7 @@ __metadata: "@babel/core": ^7.26.0 "@babel/preset-env": ^7.26.0 "@sentry/babel-plugin-component-annotate": ^2.18.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/node": 20.10.4 "@types/react": ~18.3.12 expo: ^52.0.0 @@ -24222,7 +24222,7 @@ __metadata: "@react-navigation/stack": ^6.3.20 "@sentry/core": 8.54.0 "@sentry/react": 8.54.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0 @@ -24268,7 +24268,7 @@ __metadata: "@react-navigation/native-stack": ^7.0.3 "@react-navigation/stack": ^7.0.3 "@sentry/babel-plugin-component-annotate": ^2.18.0 - "@sentry/react-native": 6.6.0 + "@sentry/react-native": 6.7.0-alpha.0 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0 From b947d7f65be63269d7c0f29a7e11b49fc165e1a7 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:13:16 +0100 Subject: [PATCH 11/21] misc(sample): Change RN Sample to use native file init by default (#4522) --- samples/react-native/android/app/build.gradle | 1 + .../reactnative/sample/MainApplication.kt | 17 +++-------- .../react-native/android/gradle.properties | 5 ++++ .../sentryreactnativesample.xcscheme | 6 ++++ .../sentryreactnativesample/AppDelegate.mm | 11 +++++-- samples/react-native/package.json | 2 ++ samples/react-native/src/App.tsx | 8 +++-- samples/react-native/src/utils.ts | 30 +++++++++++++++++++ yarn.lock | 19 ++++++++++++ 9 files changed, 81 insertions(+), 18 deletions(-) diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index 2d24b2ede1..6c0d4a7ca1 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -138,6 +138,7 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode 39 versionName "6.7.0-alpha.0" + buildConfigField "boolean", "SENTRY_DISABLE_NATIVE_START", System.getenv('SENTRY_DISABLE_NATIVE_START') ?: String.valueOf(sentryDisableNativeStart) } signingConfigs { diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt index 6546ca8b10..5b5ac47444 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt @@ -37,23 +37,14 @@ class MainApplication : override fun onCreate() { super.onCreate() - // When the native init is enabled the `autoInitializeNativeSdk` - // in JS has to be set to `false` - // this.initializeSentry() + if (!BuildConfig.SENTRY_DISABLE_NATIVE_START) { + RNSentrySDK.init(this) + } + SoLoader.init(this, OpenSourceMergedSoMapping) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { // If you opted-in for the New Architecture, we load the native entry point for this app. load() } } - - private fun initializeSentry() { - RNSentrySDK.init(this) { options -> - // Options set here will apply to the Android SDK overriding the ones from `sentry.options.json` - options.dsn = "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561" - options.isDebug = true - } - - // RNSentrySDK.init(this) - } } diff --git a/samples/react-native/android/gradle.properties b/samples/react-native/android/gradle.properties index 600fea4b77..d71a974450 100644 --- a/samples/react-native/android/gradle.properties +++ b/samples/react-native/android/gradle.properties @@ -38,3 +38,8 @@ newArchEnabled=true # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. hermesEnabled=true + +# Only implemented in this sample project. +# It's used for testing the native SDK auto-start feature. +# true means manual native start is disabled and JS auto initializes native SDK. +sentryDisableNativeStart=false diff --git a/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme b/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme index 15d942042b..61b12d2c2c 100644 --- a/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme +++ b/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme @@ -60,6 +60,12 @@ ReferencedContainer = "container:sentryreactnativesample.xcodeproj"> + + + + * arguments = [[NSProcessInfo processInfo] arguments]; + return ![arguments containsObject:@"--sentry-disable-native-start"]; +} + @end diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 143a4b6c7f..2cdee52a1d 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -29,7 +29,9 @@ "delay": "^6.0.0", "react": "18.3.1", "react-native": "0.76.3", + "react-native-build-config": "^0.3.2", "react-native-gesture-handler": "^2.21.1", + "react-native-launch-arguments": "^4.0.4", "react-native-reanimated": "3.16.1", "react-native-safe-area-context": "4.14.0", "react-native-screens": "4.1.0", diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index f6d1063736..d84f1b9470 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -31,12 +31,16 @@ import GesturesTracingScreen from './Screens/GesturesTracingScreen'; import { LogBox, Platform, StyleSheet, View } from 'react-native'; import Ionicons from 'react-native-vector-icons/Ionicons'; import PlaygroundScreen from './Screens/PlaygroundScreen'; -import { logWithoutTracing } from './utils'; +import { clearSentryOptionsFromFile, logWithoutTracing, shouldUseAutoStart } from './utils'; import { ErrorEvent } from '@sentry/core'; import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; +if (shouldUseAutoStart()) { + clearSentryOptionsFromFile(); +} + if (typeof setImmediate === 'undefined') { require('setimmediate'); } @@ -130,7 +134,7 @@ Sentry.init({ spotlight: true, // This should be disabled when manually initializing the native SDK // Note that options from JS are not passed to the native SDKs when initialized manually - autoInitializeNativeSdk: true, + // autoInitializeNativeSdk: true, }); const Stack = isMobileOs diff --git a/samples/react-native/src/utils.ts b/samples/react-native/src/utils.ts index 8681333e30..33ccfdf084 100644 --- a/samples/react-native/src/utils.ts +++ b/samples/react-native/src/utils.ts @@ -1,3 +1,7 @@ +import { LaunchArguments } from 'react-native-launch-arguments'; +import BuildConfig from 'react-native-build-config'; +import { Platform } from 'react-native'; + export function logWithoutTracing(...args: unknown[]) { if ('__sentry_original__' in console.log) { console.log.__sentry_original__(...args); @@ -5,3 +9,29 @@ export function logWithoutTracing(...args: unknown[]) { console.log(...args); } } + +export function shouldUseAutoStart(): boolean { + if (Platform.OS === 'android') { + return !!( + BuildConfig as { + SENTRY_DISABLE_NATIVE_START?: boolean; + } + ).SENTRY_DISABLE_NATIVE_START; + } else if (Platform.OS === 'ios') { + const args = LaunchArguments.value<{ + sentrydisablenativestart?: boolean; + }>(); + return !!args.sentrydisablenativestart; + } else { + return false; + } +} + +export function clearSentryOptionsFromFile() { + ( + globalThis as { + __SENTRY_OPTIONS__?: Record; + } + ).__SENTRY_OPTIONS__ = undefined; + logWithoutTracing('Sentry options from file cleared.'); +} diff --git a/yarn.lock b/yarn.lock index 64b67a8bb0..ddc9c22aa9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22463,6 +22463,13 @@ __metadata: languageName: node linkType: hard +"react-native-build-config@npm:^0.3.2": + version: 0.3.2 + resolution: "react-native-build-config@npm:0.3.2" + checksum: d2095580be7e6662c968bce5d64cc6524b7f34380999756fb9d4ad24a34e3566b203f93e9bc63bd1e0213d28e1e76dae0c0f39b0b65c55528a876e96dd6c5810 + languageName: node + linkType: hard + "react-native-codegen@npm:^0.70.7": version: 0.70.7 resolution: "react-native-codegen@npm:0.70.7" @@ -22561,6 +22568,16 @@ __metadata: languageName: node linkType: hard +"react-native-launch-arguments@npm:^4.0.4": + version: 4.0.4 + resolution: "react-native-launch-arguments@npm:4.0.4" + peerDependencies: + react: ">=16.8.1" + react-native: ">=0.60.0-rc.0 <1.0.x" + checksum: 7346af606cedc35c58bdccabd88a8ef9b2b55138accf490fe8291c6d7110679f9125af072eaaf896909554cd54be20e863c987d1ce91c39a2f401a999c7fde9f + languageName: node + linkType: hard + "react-native-macos@npm:0.73.34": version: 0.73.34 resolution: "react-native-macos@npm:0.73.34" @@ -24283,7 +24300,9 @@ __metadata: prettier: 2.8.8 react: 18.3.1 react-native: 0.76.3 + react-native-build-config: ^0.3.2 react-native-gesture-handler: ^2.21.1 + react-native-launch-arguments: ^4.0.4 react-native-reanimated: 3.16.1 react-native-safe-area-context: 4.14.0 react-native-screens: 4.1.0 From 6b08b9a7acde82841e76290744e2d6bae2053677 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:00:13 +0100 Subject: [PATCH 12/21] chore(sample-rn): Remove duplicate init options from code (#4532) --- .../sentryreactnativesample/AppDelegate.mm | 7 +++-- samples/react-native/sentry.options.json | 2 -- samples/react-native/src/App.tsx | 29 ------------------- samples/react-native/src/dsn.ts | 8 ----- 4 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 samples/react-native/src/dsn.ts diff --git a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm index fa061be54c..08e8f79765 100644 --- a/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm +++ b/samples/react-native/ios/sentryreactnativesample/AppDelegate.mm @@ -73,9 +73,10 @@ - (BOOL)concurrentRootEnabled return nullptr; } -- (BOOL) shouldStartSentry { - NSArray* arguments = [[NSProcessInfo processInfo] arguments]; - return ![arguments containsObject:@"--sentry-disable-native-start"]; +- (BOOL)shouldStartSentry +{ + NSArray *arguments = [[NSProcessInfo processInfo] arguments]; + return ![arguments containsObject:@"--sentry-disable-native-start"]; } @end diff --git a/samples/react-native/sentry.options.json b/samples/react-native/sentry.options.json index f6465b7923..53ae525bc0 100644 --- a/samples/react-native/sentry.options.json +++ b/samples/react-native/sentry.options.json @@ -11,8 +11,6 @@ "attachScreenshot": true, "attachViewHierarchy": true, "enableCaptureFailedRequests": true, - "_release": "myapp@1.2.3+1", - "_dist": 1, "profilesSampleRate": 1.0, "replaysSessionSampleRate": 1.0, "replaysOnErrorSampleRate": 1.0, diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index d84f1b9470..02b4fd2827 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -17,7 +17,6 @@ import Animated, { // Import the Sentry React Native SDK import * as Sentry from '@sentry/react-native'; -import { SENTRY_INTERNAL_DSN } from './dsn'; import ErrorsScreen from './Screens/ErrorsScreen'; import PerformanceScreen from './Screens/PerformanceScreen'; import TrackerScreen from './Screens/TrackerScreen'; @@ -55,10 +54,6 @@ const reactNavigationIntegration = Sentry.reactNavigationIntegration({ }); Sentry.init({ - // Replace the example DSN below with your own DSN: - dsn: SENTRY_INTERNAL_DSN, - debug: true, - environment: 'dev', beforeSend: (event: ErrorEvent) => { logWithoutTracing('Event beforeSend:', event.event_id); return event; @@ -74,7 +69,6 @@ Sentry.init({ didCallNativeInit, ); }, - enableUserInteractionTracing: true, integrations(integrations) { integrations.push( reactNavigationIntegration, @@ -110,30 +104,7 @@ Sentry.init({ ); return integrations.filter(i => i.name !== 'Dedupe'); }, - enableAutoSessionTracking: true, - // For testing, session close when 5 seconds (instead of the default 30) in the background. - sessionTrackingIntervalMillis: 30000, - // This will capture ALL TRACES and likely use up all your quota - enableTracing: true, - tracesSampleRate: 1.0, tracePropagationTargets: ['localhost', /^\//, /^https:\/\//, /^http:\/\//], - attachStacktrace: true, - // Attach screenshots to events. - attachScreenshot: true, - // Attach view hierarchy to events. - attachViewHierarchy: true, - // Enables capture failed requests in JS and native. - enableCaptureFailedRequests: true, - // Sets the `release` and `dist` on Sentry events. Make sure this matches EXACTLY with the values on your sourcemaps - // otherwise they will not work. - // release: 'myapp@1.2.3+1', - // dist: `1`, - profilesSampleRate: 1.0, - replaysSessionSampleRate: 1.0, - replaysOnErrorSampleRate: 1.0, - spotlight: true, - // This should be disabled when manually initializing the native SDK - // Note that options from JS are not passed to the native SDKs when initialized manually // autoInitializeNativeSdk: true, }); diff --git a/samples/react-native/src/dsn.ts b/samples/react-native/src/dsn.ts deleted file mode 100644 index 345276a627..0000000000 --- a/samples/react-native/src/dsn.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as Sentry from '@sentry/react-native'; - -export const SENTRY_INTERNAL_DSN = - 'https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561'; - -export const getCurrentDsn = () => { - return Sentry.getCurrentHub().getClient()?.getOptions().dsn; -}; From a7ffa1fdde67e7d5cc259053bb0cb688f25eea83 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:01:16 +0100 Subject: [PATCH 13/21] chore(sample-rn): Always use fhe file option (including auto init) (#4533) --- samples/react-native/src/App.tsx | 8 ++------ samples/react-native/src/utils.ts | 9 --------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 02b4fd2827..23c69e6907 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -30,16 +30,12 @@ import GesturesTracingScreen from './Screens/GesturesTracingScreen'; import { LogBox, Platform, StyleSheet, View } from 'react-native'; import Ionicons from 'react-native-vector-icons/Ionicons'; import PlaygroundScreen from './Screens/PlaygroundScreen'; -import { clearSentryOptionsFromFile, logWithoutTracing, shouldUseAutoStart } from './utils'; +import { logWithoutTracing, shouldUseAutoStart } from './utils'; import { ErrorEvent } from '@sentry/core'; import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; -if (shouldUseAutoStart()) { - clearSentryOptionsFromFile(); -} - if (typeof setImmediate === 'undefined') { require('setimmediate'); } @@ -105,7 +101,7 @@ Sentry.init({ return integrations.filter(i => i.name !== 'Dedupe'); }, tracePropagationTargets: ['localhost', /^\//, /^https:\/\//, /^http:\/\//], - // autoInitializeNativeSdk: true, + autoInitializeNativeSdk: shouldUseAutoStart(), }); const Stack = isMobileOs diff --git a/samples/react-native/src/utils.ts b/samples/react-native/src/utils.ts index 33ccfdf084..437787dcc3 100644 --- a/samples/react-native/src/utils.ts +++ b/samples/react-native/src/utils.ts @@ -26,12 +26,3 @@ export function shouldUseAutoStart(): boolean { return false; } } - -export function clearSentryOptionsFromFile() { - ( - globalThis as { - __SENTRY_OPTIONS__?: Record; - } - ).__SENTRY_OPTIONS__ = undefined; - logWithoutTracing('Sentry options from file cleared.'); -} From 28cf7b4377cfbb4510be19086ecad3958a2bbbc8 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:27:48 +0100 Subject: [PATCH 14/21] internal(sample-rn): Add Detox for integration/e2e tests of the rn sample (#4535) --- .github/workflows/sample-application.yml | 172 +++++- samples/react-native/.detoxrc.js | 119 ++++ samples/react-native/android/app/build.gradle | 8 + .../android/app/proguard-rules.pro | 4 + .../sentry/reactnative/sample/DetoxTest.java | 28 + .../android/app/src/main/AndroidManifest.xml | 3 +- .../main/res/xml/network_security_config.xml | 7 + samples/react-native/android/build.gradle | 8 + samples/react-native/e2e/jest.config.js | 13 + samples/react-native/e2e/starter.test.ts | 12 + samples/react-native/jest.config.js | 7 + samples/react-native/package.json | 4 + samples/react-native/src/App.tsx | 7 + yarn.lock | 546 +++++++++++++++++- 14 files changed, 919 insertions(+), 19 deletions(-) create mode 100644 samples/react-native/.detoxrc.js create mode 100644 samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java create mode 100644 samples/react-native/android/app/src/main/res/xml/network_security_config.xml create mode 100644 samples/react-native/e2e/jest.config.js create mode 100644 samples/react-native/e2e/starter.test.ts create mode 100644 samples/react-native/jest.config.js diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 3ec82a6e31..e4e9a8f8b3 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -14,6 +14,12 @@ concurrency: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RN_SENTRY_POD_NAME: RNSentry + IOS_APP_ARCHIVE_PATH: sentry-react-native-sample.app.zip + ANDROID_APP_ARCHIVE_PATH: sentry-react-native-sample.apk.zip + REACT_NATIVE_SAMPLE_PATH: samples/react-native + IOS_DEVICE: 'iPhone 16' + IOS_VERSION: '18.1' + ANDROID_API_LEVEL: '30' jobs: diff_check: @@ -66,7 +72,7 @@ jobs: - uses: ruby/setup-ruby@v1 if: ${{ matrix.platform == 'ios' || matrix.platform == 'macos' }} with: - working-directory: ${{ matrix.platform == 'ios' && ' samples/react-native' || ' samples/react-native-macos' }} + working-directory: ${{ matrix.platform == 'ios' && env.REACT_NATIVE_SAMPLE_PATH || ' samples/react-native-macos' }} ruby-version: '3.3.0' # based on what is used in the sample bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 1 # cache the installed gems @@ -106,7 +112,7 @@ jobs: - name: Build Android App if: ${{ matrix.platform == 'android' }} - working-directory: samples/react-native/android + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android run: | if [[ ${{ matrix.rn-architecture }} == 'new' ]]; then perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties @@ -119,11 +125,14 @@ jobs: fi [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' echo "Building $CONFIG" - ./gradlew ":app:assemble$CONFIG" -PreactNativeArchitectures=x86 + [[ "${{ matrix.build-type }}" == "production" ]] && TEST_TYPE='release' || TEST_TYPE='debug' + echo "Building $TEST_TYPE" + + ./gradlew ":app:assemble$CONFIG" app:assembleAndroidTest -DtestBuildType=$TEST_TYPE -PreactNativeArchitectures=x86 - name: Build iOS App if: ${{ matrix.platform == 'ios' }} - working-directory: samples/react-native/ios + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios run: | [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' echo "Building $CONFIG" @@ -160,9 +169,162 @@ jobs: | tee xcodebuild.log \ | xcbeautify --quieter --is-ci --disable-colored-output + - name: Archive iOS App + if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + run: | + cd ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios/DerivedData/Build/Products/Release-iphonesimulator + zip -r \ + ${{ github.workspace }}/${{ env.IOS_APP_ARCHIVE_PATH }} \ + sentryreactnativesample.app + + - name: Archive Android App + if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} + run: | + mv ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android/app/build/outputs/apk/release/app-release.apk app.apk + mv ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk app-androidTest.apk + zip -j \ + ${{ env.ANDROID_APP_ARCHIVE_PATH }} \ + app.apk \ + app-androidTest.apk + + - name: Upload iOS APP + if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + uses: actions/upload-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-${{ matrix.platform }} + path: ${{ env.IOS_APP_ARCHIVE_PATH }} + retention-days: 1 + + - name: Upload Android APK + if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} + uses: actions/upload-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.platform }} + path: ${{ env.ANDROID_APP_ARCHIVE_PATH }} + retention-days: 1 + - name: Upload logs if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: build-sample-${{ matrix.rn-architecture }}-${{ matrix.platform }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-logs - path: samples/react-native/${{ matrix.platform }}/*.log + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/${{ matrix.platform }}/*.log + + test: + name: Test ${{ matrix.platform }} ${{ matrix.build-type }} + runs-on: ${{ matrix.runs-on }} + needs: [diff_check, build] + if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} + strategy: + # we want that the matrix keeps running, default is to cancel them if it fails. + fail-fast: false + matrix: + include: + - platform: ios + runs-on: macos-15 + rn-architecture: 'new' + ios-use-frameworks: 'no-frameworks' + build-type: 'production' + + - platform: android + runs-on: ubuntu-latest + rn-architecture: 'new' + build-type: 'production' + + steps: + - uses: actions/checkout@v4 + + - name: Download iOS App Archive + if: ${{ matrix.platform == 'ios' }} + uses: actions/download-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-${{ matrix.platform }} + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + + - name: Download Android APK + if: ${{ matrix.platform == 'android' }} + uses: actions/download-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.platform }} + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + + - name: Unzip iOS App Archive + if: ${{ matrix.platform == 'ios' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: unzip ${{ env.IOS_APP_ARCHIVE_PATH }} + + - name: Unzip Android APK + if: ${{ matrix.platform == 'android' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }} + + - name: Enable Corepack + run: | + npm install -g corepack@0.29.4 + corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'yarn' + cache-dependency-path: yarn.lock + + - name: Install JS Dependencies + run: yarn install + + - name: Install Detox + run: npm install -g detox-cli@20.0.0 + + - name: Install Apple Simulator Utilities + if: ${{ matrix.platform == 'ios' }} + run: | + brew tap wix/brew + brew install applesimutils + + - name: Setup KVM + if: ${{ matrix.platform == 'android' }} + shell: bash + run: | + # check if virtualization is supported... + sudo apt install -y --no-install-recommends cpu-checker coreutils && echo "CPUs=$(nproc --all)" && kvm-ok + # allow access to KVM to run the emulator + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: futureware-tech/simulator-action@dab10d813144ef59b48d401cd95da151222ef8cd # pin@v4 + if: ${{ matrix.platform == 'ios' }} + with: + # the same envs are used by Detox ci.sim configuration + model: ${{ env.IOS_DEVICE }} + os_version: ${{ env.IOS_VERSION }} + + - name: Run Detox iOS Tests + if: ${{ matrix.platform == 'ios' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: detox test --configuration ci.sim + + - name: Run tests on Android + if: ${{ matrix.platform == 'android' }} + env: + # used by Detox ci.android configuration + ANDROID_AVD_NAME: 'test' # test is default reactivecircus/android-emulator-runner name + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 + with: + api-level: ${{ env.ANDROID_API_LEVEL }} + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + target: 'aosp_atd' + channel: canary # Necessary for ATDs + emulator-options: > + -no-window + -no-snapshot-save + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -camera-front none + -timezone US/Pacific + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + script: detox test --configuration ci.android diff --git a/samples/react-native/.detoxrc.js b/samples/react-native/.detoxrc.js new file mode 100644 index 0000000000..fd29191f91 --- /dev/null +++ b/samples/react-native/.detoxrc.js @@ -0,0 +1,119 @@ +const process = require('process'); + +/** @type {Detox.DetoxConfig} */ +module.exports = { + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.js', + }, + jest: { + setupTimeout: 120000, + }, + }, + apps: { + 'ios.debug': { + type: 'ios.app', + binaryPath: + 'ios/build/Build/Products/Debug-iphonesimulator/sentryreactnativesample.app', + build: + 'xcodebuild -workspace ios/sentryreactnativesample.xcworkspace -scheme sentryreactnativesample -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', + }, + 'ios.release': { + type: 'ios.app', + binaryPath: + 'ios/build/Build/Products/Release-iphonesimulator/sentryreactnativesample.app', + build: + 'xcodebuild -workspace ios/sentryreactnativesample.xcworkspace -scheme sentryreactnativesample -configuration Release -sdk iphonesimulator -derivedDataPath ios/build', + }, + 'android.debug': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', + build: + 'cd android && ./gradlew app:assembleDebug app:assembleAndroidTest -DtestBuildType=debug', + reversePorts: [8081], + }, + 'android.release': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', + build: + 'cd android && ./gradlew app:assembleRelease app:assembleAndroidTest -DtestBuildType=release', + }, + 'ci.android': { + type: 'android.apk', + binaryPath: 'app.apk', + testBinaryPath: 'app-androidTest.apk', + }, + 'ci.ios': { + type: 'ios.app', + binaryPath: 'sentryreactnativesample.app', + }, + }, + devices: { + simulator: { + type: 'ios.simulator', + device: { + type: 'iPhone 16', + }, + }, + attached: { + type: 'android.attached', + device: { + adbName: '.*', + }, + }, + emulator: { + type: 'android.emulator', + device: { + avdName: 'Pixel_9_API_35', + }, + }, + 'ci.emulator': { + type: 'android.emulator', + device: { + avdName: process.env.ANDROID_AVD_NAME, + }, + }, + 'ci.simulator': { + type: 'ios.simulator', + device: { + type: process.env.IOS_DEVICE, + os: process.env.IOS_VERSION, + }, + }, + }, + configurations: { + 'ios.sim.debug': { + device: 'simulator', + app: 'ios.debug', + }, + 'ios.sim.release': { + device: 'simulator', + app: 'ios.release', + }, + 'android.att.debug': { + device: 'attached', + app: 'android.debug', + }, + 'android.att.release': { + device: 'attached', + app: 'android.release', + }, + 'android.emu.debug': { + device: 'emulator', + app: 'android.debug', + }, + 'android.emu.release': { + device: 'emulator', + app: 'android.release', + }, + 'ci.android': { + device: 'ci.emulator', + app: 'ci.android', + }, + 'ci.sim': { + device: 'ci.simulator', + app: 'ci.ios', + }, + }, +}; diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index 6c0d4a7ca1..1652764dfe 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -139,6 +139,10 @@ android { versionCode 39 versionName "6.7.0-alpha.0" buildConfigField "boolean", "SENTRY_DISABLE_NATIVE_START", System.getenv('SENTRY_DISABLE_NATIVE_START') ?: String.valueOf(sentryDisableNativeStart) + + // Detox + testBuildType System.getProperty('testBuildType', 'debug') + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } signingConfigs { @@ -193,11 +197,15 @@ android { signingConfig signingConfigs.debug minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro" } } } dependencies { + androidTestImplementation('com.wix:detox:+') + implementation 'androidx.appcompat:appcompat:1.7.0' + // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") diff --git a/samples/react-native/android/app/proguard-rules.pro b/samples/react-native/android/app/proguard-rules.pro index 11b025724a..f4ada6b5a1 100644 --- a/samples/react-native/android/app/proguard-rules.pro +++ b/samples/react-native/android/app/proguard-rules.pro @@ -8,3 +8,7 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: + +# Detox Release tests were failing on missing kotlin.Result +# It should be covered by node_modules/detox/android/detox/proguard-rules-app.pro but it seems missing +-keep class kotlin.** { *; } diff --git a/samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java b/samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java new file mode 100644 index 0000000000..28b9b28d1c --- /dev/null +++ b/samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java @@ -0,0 +1,28 @@ +package io.sentry.reactnative.sample; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import com.wix.detox.Detox; +import com.wix.detox.config.DetoxConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class DetoxTest { + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(MainActivity.class, false, false); + + @Test + public void runDetoxTests() { + DetoxConfig detoxConfig = new DetoxConfig(); + detoxConfig.idlePolicyConfig.masterTimeoutSec = 90; + detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60; + detoxConfig.rnContextLoadTimeoutSec = (BuildConfig.DEBUG ? 180 : 60); + + Detox.runTests(mActivityRule, detoxConfig); + } +} diff --git a/samples/react-native/android/app/src/main/AndroidManifest.xml b/samples/react-native/android/app/src/main/AndroidManifest.xml index e1892528b8..095bdca459 100644 --- a/samples/react-native/android/app/src/main/AndroidManifest.xml +++ b/samples/react-native/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" - android:supportsRtl="true"> + android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config"> + + + 10.0.2.2 + localhost + + diff --git a/samples/react-native/android/build.gradle b/samples/react-native/android/build.gradle index c6979c3c43..c2b68ea82b 100644 --- a/samples/react-native/android/build.gradle +++ b/samples/react-native/android/build.gradle @@ -20,4 +20,12 @@ buildscript { } } +allprojects { + repositories { + maven { + url("$rootDir/../node_modules/detox/Detox-android") + } + } +} + apply plugin: "com.facebook.react.rootproject" diff --git a/samples/react-native/e2e/jest.config.js b/samples/react-native/e2e/jest.config.js new file mode 100644 index 0000000000..b52d19a014 --- /dev/null +++ b/samples/react-native/e2e/jest.config.js @@ -0,0 +1,13 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + preset: 'ts-jest', + rootDir: '..', + testMatch: ['/e2e/**/*.test.ts'], + testTimeout: 120000, + maxWorkers: 1, + globalSetup: 'detox/runners/jest/globalSetup', + globalTeardown: 'detox/runners/jest/globalTeardown', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, +}; diff --git a/samples/react-native/e2e/starter.test.ts b/samples/react-native/e2e/starter.test.ts new file mode 100644 index 0000000000..b88c9d0882 --- /dev/null +++ b/samples/react-native/e2e/starter.test.ts @@ -0,0 +1,12 @@ +import { describe, it, beforeAll } from '@jest/globals'; +import { device, expect } from 'detox'; + +describe('Shows HomeScreen', () => { + beforeAll(async () => { + await device.launchApp(); + }); + + it('Shows Bottom Tab Bar', async () => { + await expect(element(by.text('Performance'))).toBeVisible(); + }); +}); diff --git a/samples/react-native/jest.config.js b/samples/react-native/jest.config.js new file mode 100644 index 0000000000..27803eeafc --- /dev/null +++ b/samples/react-native/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + testMatch: [ + '/__tests__/**/*-test.ts', + '/__tests__/**/*-test.tsx', + ], +}; diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 2cdee52a1d..128f329fa9 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -53,6 +53,8 @@ "@react-native/metro-config": "0.76.3", "@react-native/typescript-config": "0.76.3", "@sentry/babel-plugin-component-annotate": "^2.18.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.13.1", "@types/react": "^18.2.65", "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "^18.0.0", @@ -60,12 +62,14 @@ "@typescript-eslint/parser": "^7.18.0", "babel-jest": "^29.2.1", "babel-plugin-module-resolver": "^5.0.0", + "detox": "^20.33.0", "eslint": "^8.19.0", "jest": "^29.6.3", "patch-package": "^8.0.0", "prettier": "2.8.8", "react-test-renderer": "18.3.1", "sentry-react-native-samples-utils": "workspace:^", + "ts-jest": "^29.2.5", "typescript": "5.0.4" }, "engines": { diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 23c69e6907..62d09942f2 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -36,6 +36,9 @@ import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; +/* false by default to avoid issues in e2e tests waiting for the animation end */ +const RUNNING_INDICATOR = false; + if (typeof setImmediate === 'undefined') { require('setimmediate'); } @@ -255,6 +258,10 @@ function RunningIndicator() { return null; } + if (!RUNNING_INDICATOR) { + return null; + } + return ; } diff --git a/yarn.lock b/yarn.lock index ddc9c22aa9..ca53bb1707 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4401,6 +4401,13 @@ __metadata: languageName: node linkType: hard +"@flatten-js/interval-tree@npm:^1.1.2": + version: 1.1.3 + resolution: "@flatten-js/interval-tree@npm:1.1.3" + checksum: 8ff9dc4062b20bd1bcff735b6734d93489409af59f87db799abe534d745dd8cd9293a15e720a999058bc97c66b88f1cdb14f6142d122723ffe52032c5ca2efde + languageName: node + linkType: hard + "@hapi/hoek@npm:^9.0.0, @hapi/hoek@npm:^9.3.0": version: 9.3.0 resolution: "@hapi/hoek@npm:9.3.0" @@ -8406,6 +8413,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" + dependencies: + expect: ^29.0.0 + pretty-format: ^29.0.0 + checksum: 18dba4623f26661641d757c63da2db45e9524c9be96a29ef713c703a9a53792df9ecee9f7365a0858ddbd6440d98fe6b65ca67895ca5884b73cbc7ffc11f3838 + languageName: node + linkType: hard + "@types/jsdom@npm:^20.0.0": version: 20.0.1 resolution: "@types/jsdom@npm:20.0.1" @@ -8545,6 +8562,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.13.1": + version: 22.13.1 + resolution: "@types/node@npm:22.13.1" + dependencies: + undici-types: ~6.20.0 + checksum: a0759e4bedc3fe892c3ddef5fa9cb5251f9c5b24defc1a389438ea3b5b727c481c1a9bc94bae4ecc7426c89ad293cd66633d163da1ab14d74d358cbec9e1ce31 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -9658,7 +9684,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.9.0": +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.6.3, ajv@npm:^8.9.0": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -10940,7 +10966,7 @@ __metadata: languageName: node linkType: hard -"bluebird@npm:3.7.2, bluebird@npm:^3.1.1, bluebird@npm:^3.4.7, bluebird@npm:^3.5.1, bluebird@npm:^3.5.5, bluebird@npm:^3.7.2": +"bluebird@npm:3.7.2, bluebird@npm:^3.1.1, bluebird@npm:^3.4.7, bluebird@npm:^3.5.1, bluebird@npm:^3.5.4, bluebird@npm:^3.5.5, bluebird@npm:^3.7.2": version: 3.7.2 resolution: "bluebird@npm:3.7.2" checksum: 869417503c722e7dc54ca46715f70e15f4d9c602a423a02c825570862d12935be59ed9c7ba34a9b31f186c017c23cac6b54e35446f8353059c101da73eac22ef @@ -11047,6 +11073,13 @@ __metadata: languageName: node linkType: hard +"browser-process-hrtime@npm:^1.0.0": + version: 1.0.0 + resolution: "browser-process-hrtime@npm:1.0.0" + checksum: e30f868cdb770b1201afb714ad1575dd86366b6e861900884665fb627109b3cc757c40067d3bfee1ff2a29c835257ea30725a8018a9afd02ac1c24b408b1e45f + languageName: node + linkType: hard + "browserslist@npm:^4.23.1, browserslist@npm:^4.23.3": version: 4.23.3 resolution: "browserslist@npm:4.23.3" @@ -11172,6 +11205,87 @@ __metadata: languageName: node linkType: hard +"bunyamin@npm:^1.5.2": + version: 1.6.3 + resolution: "bunyamin@npm:1.6.3" + dependencies: + "@flatten-js/interval-tree": ^1.1.2 + multi-sort-stream: ^1.0.4 + stream-json: ^1.7.5 + trace-event-lib: ^1.3.1 + peerDependencies: + "@types/bunyan": ^1.8.8 + bunyan: ^1.8.15 || ^2.0.0 + peerDependenciesMeta: + "@types/bunyan": + optional: true + bunyan: + optional: true + checksum: 3422db179c2f1d9581740b18de79c925e2ab25ee49ea5e66a5b66db16372d6f641927de55010c997050049d9e9569f4b720d409ffa0a573ded86aef5d49768eb + languageName: node + linkType: hard + +"bunyan-debug-stream@npm:^3.1.0": + version: 3.1.1 + resolution: "bunyan-debug-stream@npm:3.1.1" + dependencies: + chalk: ^4.1.2 + peerDependencies: + bunyan: "*" + peerDependenciesMeta: + bunyan: + optional: true + checksum: e0dd2c42de27857bd7c70b600ac30ecf7ef5efe7837c6ea2d87b98e48c7cd16a4fcce1d08439d9fc5dbff2d672b191357ea579750c9cd6379703109f5077bca4 + languageName: node + linkType: hard + +"bunyan@npm:^1.8.12": + version: 1.8.15 + resolution: "bunyan@npm:1.8.15" + dependencies: + dtrace-provider: ~0.8 + moment: ^2.19.3 + mv: ~2 + safe-json-stringify: ~1 + dependenciesMeta: + dtrace-provider: + optional: true + moment: + optional: true + mv: + optional: true + safe-json-stringify: + optional: true + bin: + bunyan: bin/bunyan + checksum: a479e0787c3a0b6565b54bd15f0b6c729d624c5aba53523e140e49e279b7a78508df93000e758bf6d02361117d6b4e6e5fc1d5ece05366fb6c4ba41bf1ac7d52 + languageName: node + linkType: hard + +"bunyan@npm:^2.0.5": + version: 2.0.5 + resolution: "bunyan@npm:2.0.5" + dependencies: + dtrace-provider: ~0.8 + exeunt: 1.1.0 + moment: ^2.19.3 + mv: ~2 + safe-json-stringify: ~1 + dependenciesMeta: + dtrace-provider: + optional: true + moment: + optional: true + mv: + optional: true + safe-json-stringify: + optional: true + bin: + bunyan: bin/bunyan + checksum: a932e883387e5bef23eee0f1f9af94e8b885da32492eaf7164dc58e3b42e5a65845068beb7ac8fbcff31511a55728c1a826bf48ba3e4edd7e220ebf0fe2ab989 + languageName: node + linkType: hard + "byte-size@npm:8.1.1": version: 8.1.1 resolution: "byte-size@npm:8.1.1" @@ -11235,6 +11349,13 @@ __metadata: languageName: node linkType: hard +"caf@npm:^15.0.1": + version: 15.0.1 + resolution: "caf@npm:15.0.1" + checksum: 832cc5d3a6053efb458ed1c1f5e5d3ebbc7710f2275f033c6362dcfd1565f15e29dbee15fa0f3301ecb5c4dbdc753c070b5a4a6d3dc8e246cb784cb26c601e8b + languageName: node + linkType: hard + "call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": version: 1.0.7 resolution: "call-bind@npm:1.0.7" @@ -11416,6 +11537,17 @@ __metadata: languageName: node linkType: hard +"child-process-promise@npm:^2.2.0": + version: 2.2.1 + resolution: "child-process-promise@npm:2.2.1" + dependencies: + cross-spawn: ^4.0.2 + node-version: ^1.0.0 + promise-polyfill: ^6.0.1 + checksum: fb72dda7ee78099f106d57bf3d7cc3225c16c9ddfe8e364e3535a52396482ee81aecd3eff0da7131ca17b7ba9fcbb8af827da63a03f0c3262c76268696898642 + languageName: node + linkType: hard + "chokidar@npm:^3.4.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -12281,6 +12413,16 @@ __metadata: languageName: node linkType: hard +"cross-spawn@npm:^4.0.2": + version: 4.0.2 + resolution: "cross-spawn@npm:4.0.2" + dependencies: + lru-cache: ^4.0.1 + which: ^1.2.9 + checksum: 8ce57b3e11c5c798542a21ddfdc1edef33ab6fe001958b31f3340a6ff684e3334a8baad2751efa78b6200aad442cf12b939396d758b0dd5c42c9b782c28fe06e + languageName: node + linkType: hard + "cross-spawn@npm:^6.0.0": version: 6.0.6 resolution: "cross-spawn@npm:6.0.6" @@ -12584,6 +12726,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^4.0.0": + version: 4.0.0 + resolution: "decamelize@npm:4.0.0" + checksum: b7d09b82652c39eead4d6678bb578e3bebd848add894b76d0f6b395bc45b2d692fb88d977e7cfb93c4ed6c119b05a1347cef261174916c2e75c0a8ca57da1809 + languageName: node + linkType: hard + "decamelize@npm:^6.0.0": version: 6.0.0 resolution: "decamelize@npm:6.0.0" @@ -13047,6 +13196,64 @@ __metadata: languageName: node linkType: hard +"detox-copilot@npm:^0.0.27": + version: 0.0.27 + resolution: "detox-copilot@npm:0.0.27" + checksum: 4f01ed1f21fe3128ee50037b63085fe95ccdc9e723c6b034d53720fa325123e39d4f83d18b1ab88a11a679258b0ff734e74f0738118e260f10945fadbe205443 + languageName: node + linkType: hard + +"detox@npm:^20.33.0": + version: 20.33.0 + resolution: "detox@npm:20.33.0" + dependencies: + ajv: ^8.6.3 + bunyan: ^1.8.12 + bunyan-debug-stream: ^3.1.0 + caf: ^15.0.1 + chalk: ^4.0.0 + child-process-promise: ^2.2.0 + detox-copilot: ^0.0.27 + execa: ^5.1.1 + find-up: ^5.0.0 + fs-extra: ^11.0.0 + funpermaproxy: ^1.1.0 + glob: ^8.0.3 + ini: ^1.3.4 + jest-environment-emit: ^1.0.8 + json-cycle: ^1.3.0 + lodash: ^4.17.11 + multi-sort-stream: ^1.0.3 + multipipe: ^4.0.0 + node-ipc: 9.2.1 + proper-lockfile: ^3.0.2 + resolve-from: ^5.0.0 + sanitize-filename: ^1.6.1 + semver: ^7.0.0 + serialize-error: ^8.0.1 + shell-quote: ^1.7.2 + signal-exit: ^3.0.3 + stream-json: ^1.7.4 + strip-ansi: ^6.0.1 + telnet-client: 1.2.8 + tempfile: ^2.0.0 + trace-event-lib: ^1.3.1 + which: ^1.3.1 + ws: ^7.0.0 + yargs: ^17.0.0 + yargs-parser: ^21.0.0 + yargs-unparser: ^2.0.0 + peerDependencies: + jest: 29.x.x || 28.x.x || ^27.2.5 + peerDependenciesMeta: + jest: + optional: true + bin: + detox: local-cli/cli.js + checksum: 14a9a230f02c6e7e535e96223a9aacbef05c06c20887eac3d1f1df1aca612a1c529f94e265064df39d1c98ee700fbc2a26ebdd18affea5e60f17a527ae42d6e0 + languageName: node + linkType: hard + "devtools-protocol@npm:0.0.1232444": version: 0.0.1232444 resolution: "devtools-protocol@npm:0.0.1232444" @@ -13201,6 +13408,25 @@ __metadata: languageName: node linkType: hard +"dtrace-provider@npm:~0.8": + version: 0.8.8 + resolution: "dtrace-provider@npm:0.8.8" + dependencies: + nan: ^2.14.0 + node-gyp: latest + checksum: f2dc89df6a9c443dc9bae3b53496e0685b5da89142951d451c1ce062c75d96698ffc0b3d90f621a59a6a18578be552378ad4e08210759038910ff2080be556b9 + languageName: node + linkType: hard + +"duplexer2@npm:^0.1.2": + version: 0.1.4 + resolution: "duplexer2@npm:0.1.4" + dependencies: + readable-stream: ^2.0.2 + checksum: 744961f03c7f54313f90555ac20284a3fb7bf22fdff6538f041a86c22499560eb6eac9d30ab5768054137cb40e6b18b40f621094e0261d7d8c35a37b7a5ad241 + languageName: node + linkType: hard + "duplexer@npm:^0.1.1, duplexer@npm:~0.1.1": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -13215,6 +13441,13 @@ __metadata: languageName: node linkType: hard +"easy-stack@npm:^1.0.1": + version: 1.0.1 + resolution: "easy-stack@npm:1.0.1" + checksum: 161a99e497b3857b0be4ec9e1ebbe90b241ea9d84702f9881b8e5b3f6822065b8c4e33436996935103e191bffba3607de70712a792f4d406a050def48c6bc381 + languageName: node + linkType: hard + "edge-paths@npm:^3.0.5": version: 3.0.5 resolution: "edge-paths@npm:3.0.5" @@ -14209,6 +14442,13 @@ __metadata: languageName: node linkType: hard +"event-pubsub@npm:4.3.0": + version: 4.3.0 + resolution: "event-pubsub@npm:4.3.0" + checksum: 6940f57790c01a967b7c637f1c9fd000ee968a1d5894186ffb3356ffbe174c70e22e62adbbcfcee3f305482d99b6abe7613c1c27c909b07adc9127dc16c8cf73 + languageName: node + linkType: hard + "event-target-shim@npm:^5.0.0, event-target-shim@npm:^5.0.1": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -14286,6 +14526,13 @@ __metadata: languageName: node linkType: hard +"exeunt@npm:1.1.0": + version: 1.1.0 + resolution: "exeunt@npm:1.1.0" + checksum: c0054fa49d7b3abbc2acecd4c6e34c6ce3a0370f9c31d18cdf64dad6be9a6d3fb84d93be892b7d1906f3f23051b3855bde7b255129fc49605a04392f69e98ea2 + languageName: node + linkType: hard + "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -15161,6 +15408,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^11.0.0": + version: 11.3.0 + resolution: "fs-extra@npm:11.3.0" + dependencies: + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: f983c706e0c22b0c0747a8e9c76aed6f391ba2d76734cf2757cd84da13417b402ed68fe25bace65228856c61d36d3b41da198f1ffbf33d0b34283a2f7a62c6e9 + languageName: node + linkType: hard + "fs-extra@npm:^11.1.0, fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" @@ -15288,6 +15546,13 @@ __metadata: languageName: node linkType: hard +"funpermaproxy@npm:^1.1.0": + version: 1.1.0 + resolution: "funpermaproxy@npm:1.1.0" + checksum: 74cf0aafeadbd79053324f1fb981c1a4358618722ad01c65bd1466b42498fd07acb7749ab9224b25fc8e81c2e1283b92ceee61dded265bd7527b225351db998b + languageName: node + linkType: hard + "gauge@npm:^5.0.0": version: 5.0.2 resolution: "gauge@npm:5.0.2" @@ -15652,7 +15917,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.1.0": +"glob@npm:^8.0.3, glob@npm:^8.1.0": version: 8.1.0 resolution: "glob@npm:8.1.0" dependencies: @@ -16782,6 +17047,13 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^2.1.0": + version: 2.1.0 + resolution: "is-plain-obj@npm:2.1.0" + checksum: cec9100678b0a9fe0248a81743041ed990c2d4c99f893d935545cfbc42876cbe86d207f3b895700c690ad2fa520e568c44afc1605044b535a7820c1d40e38daa + languageName: node + linkType: hard + "is-plain-obj@npm:^4.1.0": version: 4.1.0 resolution: "is-plain-obj@npm:4.1.0" @@ -17279,6 +17551,39 @@ __metadata: languageName: node linkType: hard +"jest-environment-emit@npm:^1.0.8": + version: 1.0.8 + resolution: "jest-environment-emit@npm:1.0.8" + dependencies: + bunyamin: ^1.5.2 + bunyan: ^2.0.5 + bunyan-debug-stream: ^3.1.0 + funpermaproxy: ^1.1.0 + lodash.merge: ^4.6.2 + node-ipc: 9.2.1 + strip-ansi: ^6.0.0 + tslib: ^2.5.3 + peerDependencies: + "@jest/environment": ">=27.2.5" + "@jest/types": ">=27.2.5" + jest: ">=27.2.5" + jest-environment-jsdom: ">=27.2.5" + jest-environment-node: ">=27.2.5" + peerDependenciesMeta: + "@jest/environment": + optional: true + "@jest/types": + optional: true + jest: + optional: true + jest-environment-jsdom: + optional: true + jest-environment-node: + optional: true + checksum: 0c7bafbd3a6e5952f6abb45958f0d2997371d29b29f3876afda48d1d734ccd703577aaac0d5afec2e19dc33a9db0e9458721fe73dbe797f0ced21481d908acfd + languageName: node + linkType: hard + "jest-environment-jsdom@npm:^29.2.1, jest-environment-jsdom@npm:^29.6.2": version: 29.7.0 resolution: "jest-environment-jsdom@npm:29.7.0" @@ -17781,6 +18086,22 @@ __metadata: languageName: node linkType: hard +"js-message@npm:1.0.7": + version: 1.0.7 + resolution: "js-message@npm:1.0.7" + checksum: 18dcc4d80356e8b5be978ca7838d96d4e350a1cb8adc5741c229dec6df09f53bfed7c75c1f360171d2d791a14e2f077d6c2b1013ba899ded7a27d7dfcd4f3784 + languageName: node + linkType: hard + +"js-queue@npm:2.0.2": + version: 2.0.2 + resolution: "js-queue@npm:2.0.2" + dependencies: + easy-stack: ^1.0.1 + checksum: 5049c3f648315ed13e46755704ff5453df70f7e8e1812acf1f98d6700efbec32421f76294a0e63fd2a9f8aabaf124233bbb308f9a2caec9d9f3d833ab5a73079 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -17973,6 +18294,13 @@ __metadata: languageName: node linkType: hard +"json-cycle@npm:^1.3.0": + version: 1.5.0 + resolution: "json-cycle@npm:1.5.0" + checksum: 0a44cd349676c6726093c64283fb75402f9104b32325b06c9270af6d639e7caac419f5301a39298aef2ac1659b273b167e02bd622e628c3392cf86f0e77a9f78 + languageName: node + linkType: hard + "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -18805,6 +19133,16 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^4.0.1": + version: 4.1.5 + resolution: "lru-cache@npm:4.1.5" + dependencies: + pseudomap: ^1.0.2 + yallist: ^2.1.2 + checksum: 4bb4b58a36cd7dc4dcec74cbe6a8f766a38b7426f1ff59d4cf7d82a2aa9b9565cd1cb98f6ff60ce5cd174524868d7bc9b7b1c294371851356066ca9ac4cf135a + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -20258,7 +20596,7 @@ __metadata: languageName: node linkType: hard -"moment@npm:2.30.1, moment@npm:^2.24.0, moment@npm:^2.29.4": +"moment@npm:2.30.1, moment@npm:^2.19.3, moment@npm:^2.24.0, moment@npm:^2.29.4": version: 2.30.1 resolution: "moment@npm:2.30.1" checksum: 859236bab1e88c3e5802afcf797fc801acdbd0ee509d34ea3df6eea21eb6bcc2abd4ae4e4e64aa7c986aa6cba563c6e62806218e6412a765010712e5fa121ba6 @@ -20313,6 +20651,13 @@ __metadata: languageName: node linkType: hard +"multi-sort-stream@npm:^1.0.3, multi-sort-stream@npm:^1.0.4": + version: 1.0.4 + resolution: "multi-sort-stream@npm:1.0.4" + checksum: b234754e0e7489623f5184ba0e887ffd8014fe829c846fd8a95569339b6e19a616ae1d44f3d064279adfbf92fa5c4d016a89fc5026e16dbd680ebd67067b19a0 + languageName: node + linkType: hard + "multimatch@npm:5.0.0": version: 5.0.0 resolution: "multimatch@npm:5.0.0" @@ -20326,6 +20671,16 @@ __metadata: languageName: node linkType: hard +"multipipe@npm:^4.0.0": + version: 4.0.0 + resolution: "multipipe@npm:4.0.0" + dependencies: + duplexer2: ^0.1.2 + object-assign: ^4.1.0 + checksum: 5a494ec2ce5bfdb389882ca595e3c4a33cae6c90dad879db2e3aa9a94484d8b164b0fb7b58ccf7593ae7e8c6213fd3f53a736b2c98e4f14c5ed1d38debc33f98 + languageName: node + linkType: hard + "mute-stream@npm:0.0.7": version: 0.0.7 resolution: "mute-stream@npm:0.0.7" @@ -20347,7 +20702,7 @@ __metadata: languageName: node linkType: hard -"mv@npm:2.1.1": +"mv@npm:2.1.1, mv@npm:~2": version: 2.1.1 resolution: "mv@npm:2.1.1" dependencies: @@ -20369,6 +20724,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.14.0": + version: 2.22.0 + resolution: "nan@npm:2.22.0" + dependencies: + node-gyp: latest + checksum: 222e3a090e326c72f6782d948f44ee9b81cfb2161d5fe53216f04426a273fd094deee9dcc6813096dd2397689a2b10c1a92d3885d2e73fd2488a51547beb2929 + languageName: node + linkType: hard + "nanoid@npm:3.3.7, nanoid@npm:^3.1.23, nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -20546,6 +20910,17 @@ __metadata: languageName: node linkType: hard +"node-ipc@npm:9.2.1": + version: 9.2.1 + resolution: "node-ipc@npm:9.2.1" + dependencies: + event-pubsub: 4.3.0 + js-message: 1.0.7 + js-queue: 2.0.2 + checksum: a38aa4c8ca4317b293e0ce21f0a3a4941fc51c054800b35e263fcfe3e0feeb60e7d2c497f015054b28783316c6e7d9cc3837af9d9958bcbd8c577d0cdf6964b7 + languageName: node + linkType: hard + "node-java-connector@npm:1.1.1": version: 1.1.1 resolution: "node-java-connector@npm:1.1.1" @@ -20616,6 +20991,13 @@ __metadata: languageName: node linkType: hard +"node-version@npm:^1.0.0": + version: 1.2.0 + resolution: "node-version@npm:1.2.0" + checksum: 74e92d2a7f0fe0fce3aafd6dcc30b3b440999df68b3d92fcefcad2a52b37bc29c6b542f33760229390bfdc1a4d993fb65b9c199b1f0d568969d07fc1c04bc1e7 + languageName: node + linkType: hard + "nopt@npm:^7.0.0, nopt@npm:^7.2.0, nopt@npm:^7.2.1": version: 7.2.1 resolution: "nopt@npm:7.2.1" @@ -22075,6 +22457,13 @@ __metadata: languageName: node linkType: hard +"promise-polyfill@npm:^6.0.1": + version: 6.1.0 + resolution: "promise-polyfill@npm:6.1.0" + checksum: 6f1899cca37e48f67a424842282acd525d8d99d3536f2d97e37a117cfc4a0006683330ceaf5a15fbc09b4450f319a680292f9970a5f8e9cf90acbce0bdb0f751 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -22133,6 +22522,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^3.0.2": + version: 3.2.0 + resolution: "proper-lockfile@npm:3.2.0" + dependencies: + graceful-fs: ^4.1.11 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 1be1bb702b9d47bdf18d75f22578f51370781feba7d2617f70ff8c66a86bcfa6e55b4f69c57fc326380110f2d1ffdb6e54a4900814bf156c04ee4eb2d3c065aa + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -22180,6 +22580,13 @@ __metadata: languageName: node linkType: hard +"pseudomap@npm:^1.0.2": + version: 1.0.2 + resolution: "pseudomap@npm:1.0.2" + checksum: 856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5 + languageName: node + linkType: hard + "psl@npm:^1.1.33": version: 1.9.0 resolution: "psl@npm:1.9.0" @@ -23257,7 +23664,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -23949,6 +24356,13 @@ __metadata: languageName: node linkType: hard +"safe-json-stringify@npm:~1": + version: 1.2.0 + resolution: "safe-json-stringify@npm:1.2.0" + checksum: 5bb32db6d6a3ceb3752df51f4043a412419cd3d4fcd5680a865dfa34cd7e575ba659c077d13f52981ced084061df9c75c7fb12e391584d4264e6914c1cd3d216 + languageName: node + linkType: hard + "safe-regex-test@npm:^1.0.3": version: 1.0.3 resolution: "safe-regex-test@npm:1.0.3" @@ -23974,7 +24388,7 @@ __metadata: languageName: node linkType: hard -"sanitize-filename@npm:1.6.3": +"sanitize-filename@npm:1.6.3, sanitize-filename@npm:^1.6.1": version: 1.6.3 resolution: "sanitize-filename@npm:1.6.3" dependencies: @@ -24286,6 +24700,8 @@ __metadata: "@react-navigation/stack": ^7.0.3 "@sentry/babel-plugin-component-annotate": ^2.18.0 "@sentry/react-native": 6.7.0-alpha.0 + "@types/jest": ^29.5.14 + "@types/node": ^22.13.1 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0 @@ -24294,6 +24710,7 @@ __metadata: babel-jest: ^29.2.1 babel-plugin-module-resolver: ^5.0.0 delay: ^6.0.0 + detox: ^20.33.0 eslint: ^8.19.0 jest: ^29.6.3 patch-package: ^8.0.0 @@ -24313,6 +24730,7 @@ __metadata: react-test-renderer: 18.3.1 redux: ^4.2.1 sentry-react-native-samples-utils: "workspace:^" + ts-jest: ^29.2.5 typescript: 5.0.4 languageName: unknown linkType: soft @@ -24366,6 +24784,15 @@ __metadata: languageName: node linkType: hard +"serialize-error@npm:^8.0.1": + version: 8.1.0 + resolution: "serialize-error@npm:8.1.0" + dependencies: + type-fest: ^0.20.2 + checksum: 2eef236d50edd2d7926e602c14fb500dc3a125ee52e9f08f67033181b8e0be5d1122498bdf7c23c80683cddcad083a27974e9e7111ce23165f4d3bcdd6d65102 + languageName: node + linkType: hard + "serve-favicon@npm:2.5.0": version: 2.5.0 resolution: "serve-favicon@npm:2.5.0" @@ -24576,6 +25003,13 @@ __metadata: languageName: node linkType: hard +"shell-quote@npm:^1.7.2": + version: 1.8.2 + resolution: "shell-quote@npm:1.8.2" + checksum: 1e97b62ced1c4c5135015978ebf273bed1f425a68cf84163e83fbb0f34b3ff9471e656720dab2b7cbb4ae0f58998e686d17d166c28dfb3662acd009e8bd7faed + languageName: node + linkType: hard + "shelljs@npm:^0.8.3": version: 0.8.5 resolution: "shelljs@npm:0.8.5" @@ -25004,6 +25438,13 @@ __metadata: languageName: node linkType: hard +"stream-chain@npm:^2.2.5": + version: 2.2.5 + resolution: "stream-chain@npm:2.2.5" + checksum: c83cbf504bd11e2bcbe761a92801295b3decac7ffa4092ceffca2eb1b5d0763bcc511fa22cd8044e8a18c21ca66794fd10c8d9cd1292a3e6c0d83a4194c6b8ed + languageName: node + linkType: hard + "stream-combiner@npm:^0.2.2": version: 0.2.2 resolution: "stream-combiner@npm:0.2.2" @@ -25014,6 +25455,15 @@ __metadata: languageName: node linkType: hard +"stream-json@npm:^1.7.4, stream-json@npm:^1.7.5": + version: 1.9.1 + resolution: "stream-json@npm:1.9.1" + dependencies: + stream-chain: ^2.2.5 + checksum: 2ebf0648f9ed82ee79727a9a47805231a70d5032e0c21cee3e05cd3c449d3ce49c72b371555447eeef55904bae22ac64be8ae6086fc6cce0b83b3aa617736b64 + languageName: node + linkType: hard + "stream-slice@npm:^0.1.2": version: 0.1.2 resolution: "stream-slice@npm:0.1.2" @@ -25591,7 +26041,16 @@ __metadata: languageName: node linkType: hard -"temp-dir@npm:1.0.0": +"telnet-client@npm:1.2.8": + version: 1.2.8 + resolution: "telnet-client@npm:1.2.8" + dependencies: + bluebird: ^3.5.4 + checksum: d2430c5449a46f6f4f9a7c2c648164f014c308aa0d3207a4d6b5b7f0e443322d07b180ecac63ad43eadb6557c8ef5ae7dce1ea6276464c8c82c8c6a9c9c01bf2 + languageName: node + linkType: hard + +"temp-dir@npm:1.0.0, temp-dir@npm:^1.0.0": version: 1.0.0 resolution: "temp-dir@npm:1.0.0" checksum: cb2b58ddfb12efa83e939091386ad73b425c9a8487ea0095fe4653192a40d49184a771a1beba99045fbd011e389fd563122d79f54f82be86a55620667e08a6b2 @@ -25624,6 +26083,16 @@ __metadata: languageName: node linkType: hard +"tempfile@npm:^2.0.0": + version: 2.0.0 + resolution: "tempfile@npm:2.0.0" + dependencies: + temp-dir: ^1.0.0 + uuid: ^3.0.1 + checksum: 8a92a0f57e0ae457dfbc156b14c427b42048a86ca6bade311835cc2aeda61b25b82d688f71f2d663dde6f172f479ed07293b53f7981e41cb6f9120a3eb4fe797 + languageName: node + linkType: hard + "tempy@npm:^0.7.1": version: 0.7.1 resolution: "tempy@npm:0.7.1" @@ -25825,6 +26294,15 @@ __metadata: languageName: node linkType: hard +"trace-event-lib@npm:^1.3.1": + version: 1.4.1 + resolution: "trace-event-lib@npm:1.4.1" + dependencies: + browser-process-hrtime: ^1.0.0 + checksum: f10dbfeccee9ec80a8cf69ecadd49fa609fc2593fb50a83cc4b664524c0531f91009134bf54302f9c4911afed119b0eebb8d2724723fc44516e24a40aaae9219 + languageName: node + linkType: hard + "treeverse@npm:^3.0.0": version: 3.0.0 resolution: "treeverse@npm:3.0.0" @@ -25878,7 +26356,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.1.1": +"ts-jest@npm:^29.1.1, ts-jest@npm:^29.2.5": version: 29.2.5 resolution: "ts-jest@npm:29.2.5" dependencies: @@ -26006,6 +26484,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.5.3": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -26398,6 +26883,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.20.0": + version: 6.20.0 + resolution: "undici-types@npm:6.20.0" + checksum: b7bc50f012dc6afbcce56c9fd62d7e86b20a62ff21f12b7b5cbf1973b9578d90f22a9c7fe50e638e96905d33893bf2f9f16d98929c4673c2480de05c6c96ea8b + languageName: node + linkType: hard + "undici@npm:^6.11.1, undici@npm:^6.18.2": version: 6.21.1 resolution: "undici@npm:6.21.1" @@ -26657,6 +27149,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^3.0.1": + version: 3.4.0 + resolution: "uuid@npm:3.4.0" + bin: + uuid: ./bin/uuid + checksum: 58de2feed61c59060b40f8203c0e4ed7fd6f99d42534a499f1741218a1dd0c129f4aa1de797bcf822c8ea5da7e4137aa3673431a96dae729047f7aca7b27866f + languageName: node + linkType: hard + "uuid@npm:^7.0.3": version: 7.0.3 resolution: "uuid@npm:7.0.3" @@ -27029,7 +27530,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^1.2.9": +"which@npm:^1.2.9, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -27247,7 +27748,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.5.1, ws@npm:^7.5.10": +"ws@npm:^7, ws@npm:^7.0.0, ws@npm:^7.5.1, ws@npm:^7.5.10": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: @@ -27378,6 +27879,13 @@ __metadata: languageName: node linkType: hard +"yallist@npm:^2.1.2": + version: 2.1.2 + resolution: "yallist@npm:2.1.2" + checksum: 9ba99409209f485b6fcb970330908a6d41fa1c933f75e08250316cce19383179a6b70a7e0721b89672ebb6199cc377bf3e432f55100da6a7d6e11902b0a642cb + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -27408,7 +27916,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": +"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.0.0, yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c @@ -27432,7 +27940,19 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": +"yargs-unparser@npm:^2.0.0": + version: 2.0.0 + resolution: "yargs-unparser@npm:2.0.0" + dependencies: + camelcase: ^6.0.0 + decamelize: ^4.0.0 + flat: ^5.0.2 + is-plain-obj: ^2.1.0 + checksum: 68f9a542c6927c3768c2f16c28f71b19008710abd6b8f8efbac6dcce26bbb68ab6503bed1d5994bdbc2df9a5c87c161110c1dfe04c6a3fe5c6ad1b0e15d9a8a3 + languageName: node + linkType: hard + +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From cf00d4dec2822985acf7a6bb70b99d4645ad576e Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 17 Feb 2025 16:44:39 +0100 Subject: [PATCH 15/21] internal(sample-rn): Add header and message envelope tests (#4536) --- .github/workflows/sample-application.yml | 4 + samples/react-native/.detoxrc.js | 38 +++++++-- .../e2e/captureMessage.test.android.ts | 49 +++++++++++ .../e2e/captureMessage.test.ios.ts | 46 ++++++++++ .../e2e/envelopeHeader.test.android.ts | 63 ++++++++++++++ .../e2e/envelopeHeader.test.ios.ts | 69 +++++++++++++++ .../react-native/e2e/jest.config.android.js | 16 ++++ samples/react-native/e2e/jest.config.ios.js | 13 +++ samples/react-native/e2e/utils/consts.ts | 2 + .../e2e/utils/mockedSentryServer.ts | 84 +++++++++++++++++++ .../react-native/e2e/utils/parseEnvelope.ts | 74 ++++++++++++++++ samples/react-native/e2e/utils/tap.ts | 14 ++++ samples/react-native/package.json | 1 + samples/react-native/scripts/dsn.mjs | 24 ++++++ samples/react-native/scripts/set-aos-dsn.mjs | 5 ++ samples/react-native/scripts/set-ios-dsn.mjs | 5 ++ yarn.lock | 1 + 17 files changed, 499 insertions(+), 9 deletions(-) create mode 100644 samples/react-native/e2e/captureMessage.test.android.ts create mode 100644 samples/react-native/e2e/captureMessage.test.ios.ts create mode 100644 samples/react-native/e2e/envelopeHeader.test.android.ts create mode 100644 samples/react-native/e2e/envelopeHeader.test.ios.ts create mode 100644 samples/react-native/e2e/jest.config.android.js create mode 100644 samples/react-native/e2e/jest.config.ios.js create mode 100644 samples/react-native/e2e/utils/consts.ts create mode 100644 samples/react-native/e2e/utils/mockedSentryServer.ts create mode 100644 samples/react-native/e2e/utils/parseEnvelope.ts create mode 100644 samples/react-native/e2e/utils/tap.ts create mode 100644 samples/react-native/scripts/dsn.mjs create mode 100755 samples/react-native/scripts/set-aos-dsn.mjs create mode 100755 samples/react-native/scripts/set-ios-dsn.mjs diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index e4e9a8f8b3..53202a0f31 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -114,6 +114,8 @@ jobs: if: ${{ matrix.platform == 'android' }} working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android run: | + ../scripts/set-aos-dsn.mjs + if [[ ${{ matrix.rn-architecture }} == 'new' ]]; then perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties echo 'New Architecture enabled' @@ -134,6 +136,8 @@ jobs: if: ${{ matrix.platform == 'ios' }} working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios run: | + ../scripts/set-ios-dsn.mjs + [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' echo "Building $CONFIG" mkdir -p "DerivedData" diff --git a/samples/react-native/.detoxrc.js b/samples/react-native/.detoxrc.js index fd29191f91..e6ba0d66cd 100644 --- a/samples/react-native/.detoxrc.js +++ b/samples/react-native/.detoxrc.js @@ -1,16 +1,28 @@ const process = require('process'); +const testRunnerIos = { + args: { + $0: 'jest', + config: 'e2e/jest.config.ios.js', + }, + jest: { + setupTimeout: 120000, + }, +}; + +const testRunnerAos = { + args: { + $0: 'jest', + config: 'e2e/jest.config.android.js', + }, + jest: { + setupTimeout: 120000, + }, +}; + /** @type {Detox.DetoxConfig} */ module.exports = { - testRunner: { - args: { - $0: 'jest', - config: 'e2e/jest.config.js', - }, - jest: { - setupTimeout: 120000, - }, - }, + testRunner: {}, apps: { 'ios.debug': { type: 'ios.app', @@ -86,34 +98,42 @@ module.exports = { 'ios.sim.debug': { device: 'simulator', app: 'ios.debug', + testRunner: testRunnerIos, }, 'ios.sim.release': { device: 'simulator', app: 'ios.release', + testRunner: testRunnerIos, }, 'android.att.debug': { device: 'attached', app: 'android.debug', + testRunner: testRunnerAos, }, 'android.att.release': { device: 'attached', app: 'android.release', + testRunner: testRunnerAos, }, 'android.emu.debug': { device: 'emulator', app: 'android.debug', + testRunner: testRunnerAos, }, 'android.emu.release': { device: 'emulator', app: 'android.release', + testRunner: testRunnerAos, }, 'ci.android': { device: 'ci.emulator', app: 'ci.android', + testRunner: testRunnerAos, }, 'ci.sim': { device: 'ci.simulator', app: 'ci.ios', + testRunner: testRunnerIos, }, }, }; diff --git a/samples/react-native/e2e/captureMessage.test.android.ts b/samples/react-native/e2e/captureMessage.test.android.ts new file mode 100644 index 0000000000..a56d3704bc --- /dev/null +++ b/samples/react-native/e2e/captureMessage.test.android.ts @@ -0,0 +1,49 @@ +import { describe, it, beforeAll, expect } from '@jest/globals'; +import { Envelope } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEvent, +} from './utils/mockedSentryServer'; +import { HEADER, ITEMS } from './utils/consts'; +import { tap } from './utils/tap'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains message event', async () => { + const item = (envelope[ITEMS] as [{ type?: string }, unknown][]).find( + i => i[HEADER].type === 'event', + ); + + expect(item).toEqual([ + { + content_type: 'application/json', + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + level: 'info', + message: { + message: 'Captured message', + }, + platform: 'javascript', + }), + ]); + }); +}); diff --git a/samples/react-native/e2e/captureMessage.test.ios.ts b/samples/react-native/e2e/captureMessage.test.ios.ts new file mode 100644 index 0000000000..25c953b707 --- /dev/null +++ b/samples/react-native/e2e/captureMessage.test.ios.ts @@ -0,0 +1,46 @@ +import { describe, it, beforeAll, expect } from '@jest/globals'; +import { Envelope } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEvent, +} from './utils/mockedSentryServer'; +import { HEADER, ITEMS } from './utils/consts'; +import { tap } from './utils/tap'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains message event', async () => { + const item = (envelope[ITEMS] as [{ type?: string }, unknown][]).find( + i => i[HEADER].type === 'event', + ); + + expect(item).toEqual([ + { + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + level: 'info', + message: 'Captured message', + platform: 'javascript', + }), + ]); + }); +}); diff --git a/samples/react-native/e2e/envelopeHeader.test.android.ts b/samples/react-native/e2e/envelopeHeader.test.android.ts new file mode 100644 index 0000000000..26700f9245 --- /dev/null +++ b/samples/react-native/e2e/envelopeHeader.test.android.ts @@ -0,0 +1,63 @@ +import { describe, it, beforeAll, expect } from '@jest/globals'; +import { Envelope } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEvent, +} from './utils/mockedSentryServer'; +import { HEADER } from './utils/consts'; +import { tap } from './utils/tap'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('contains event_id and sent_at in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + event_id: expect.any(String), + sent_at: expect.any(String), + }), + ); + }); + + it('contains sdk info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + sdk: { + name: 'sentry.javascript.react-native', + version: expect.any(String), + }, + sent_at: expect.any(String), + }), + ); + }); + + it('contains trace info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + trace: { + environment: expect.any(String), + public_key: expect.any(String), + replay_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + ); + }); +}); diff --git a/samples/react-native/e2e/envelopeHeader.test.ios.ts b/samples/react-native/e2e/envelopeHeader.test.ios.ts new file mode 100644 index 0000000000..4b72f76e18 --- /dev/null +++ b/samples/react-native/e2e/envelopeHeader.test.ios.ts @@ -0,0 +1,69 @@ +import { describe, it, beforeAll, expect } from '@jest/globals'; +import { Envelope } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEvent, +} from './utils/mockedSentryServer'; +import { HEADER } from './utils/consts'; +import { tap } from './utils/tap'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('contains event_id and sent_at in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + event_id: expect.any(String), + sent_at: expect.any(String), + }), + ); + }); + + it('contains sdk info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + sdk: { + features: [], + integrations: [], + name: 'sentry.javascript.react-native', + packages: [], + version: expect.any(String), + }, + sent_at: expect.any(String), + }), + ); + }); + + it('contains trace info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + trace: { + environment: expect.any(String), + public_key: expect.any(String), + replay_id: expect.any(String), + sample_rate: '1', + sampled: '1', + trace_id: expect.any(String), + transaction: 'ErrorsScreen', + }, + }), + ); + }); +}); diff --git a/samples/react-native/e2e/jest.config.android.js b/samples/react-native/e2e/jest.config.android.js new file mode 100644 index 0000000000..2d755851a0 --- /dev/null +++ b/samples/react-native/e2e/jest.config.android.js @@ -0,0 +1,16 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + preset: 'ts-jest', + rootDir: '..', + testMatch: [ + '/e2e/**/*.test.ts', + '/e2e/**/*.test.android.ts', + ], + testTimeout: 120000, + maxWorkers: 1, + globalSetup: 'detox/runners/jest/globalSetup', + globalTeardown: 'detox/runners/jest/globalTeardown', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, +}; diff --git a/samples/react-native/e2e/jest.config.ios.js b/samples/react-native/e2e/jest.config.ios.js new file mode 100644 index 0000000000..62034bc390 --- /dev/null +++ b/samples/react-native/e2e/jest.config.ios.js @@ -0,0 +1,13 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + preset: 'ts-jest', + rootDir: '..', + testMatch: ['/e2e/**/*.test.ts', '/e2e/**/*.test.ios.ts'], + testTimeout: 120000, + maxWorkers: 1, + globalSetup: 'detox/runners/jest/globalSetup', + globalTeardown: 'detox/runners/jest/globalTeardown', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, +}; diff --git a/samples/react-native/e2e/utils/consts.ts b/samples/react-native/e2e/utils/consts.ts new file mode 100644 index 0000000000..9a751a5fa4 --- /dev/null +++ b/samples/react-native/e2e/utils/consts.ts @@ -0,0 +1,2 @@ +export const HEADER = 0; +export const ITEMS = 1; diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts new file mode 100644 index 0000000000..40667b0f9d --- /dev/null +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -0,0 +1,84 @@ +import { IncomingMessage, ServerResponse, createServer } from 'node:http'; +import { createGunzip } from 'node:zlib'; +import { Envelope } from '@sentry/core'; +import { parseEnvelope } from './parseEnvelope'; + +type RecordedRequest = { + path: string | undefined; + headers: Record; + body: Buffer; + envelope: Envelope; +}; + +export function createSentryServer({ port = 8961 } = {}): { + waitForEnvelope: ( + predicate: (envelope: Envelope) => boolean, + ) => Promise; + close: () => Promise; + start: () => void; +} { + let onNextRequestCallback: (request: RecordedRequest) => void = () => {}; + const requests: RecordedRequest[] = []; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + let body: Buffer = Buffer.from([]); + + const gunzip = createGunzip(); + req.pipe(gunzip); + + gunzip.on('data', (chunk: Buffer) => { + body = Buffer.concat([body, chunk]); + }); + + gunzip.on('end', () => { + const request = { + path: req.url, + headers: req.headers, + body: body, + envelope: parseEnvelope(body), + }; + requests.push(request); + + body = Buffer.from([]); + + res.writeHead(200); + res.end('OK'); + + onNextRequestCallback(request); + }); + }); + + return { + start: () => { + server.listen(port); + }, + waitForEnvelope: async ( + predicate: (envelope: Envelope) => boolean, + ): Promise => { + return new Promise((resolve, reject) => { + onNextRequestCallback = (request: RecordedRequest) => { + try { + if (predicate(request.envelope)) { + resolve(request.envelope); + return; + } + } catch (e) { + reject(e); + return; + } + }; + }); + }, + close: async () => { + await new Promise(resolve => { + server.close(() => resolve()); + }); + }, + }; +} + +export function containingEvent(envelope: Envelope) { + return envelope[1].some( + item => (item[0] as { type?: string }).type === 'event', + ); +} diff --git a/samples/react-native/e2e/utils/parseEnvelope.ts b/samples/react-native/e2e/utils/parseEnvelope.ts new file mode 100644 index 0000000000..e6b29b201e --- /dev/null +++ b/samples/react-native/e2e/utils/parseEnvelope.ts @@ -0,0 +1,74 @@ +import { + Envelope, + BaseEnvelopeHeaders, + BaseEnvelopeItemHeaders, +} from '@sentry/core'; + +/** + * Parses an envelope + */ +export function parseEnvelope(env: string | Uint8Array): Envelope { + let buffer = typeof env === 'string' ? encodeUTF8(env) : env; + + function readBinary(length?: number): Uint8Array { + if (!length) { + throw new Error('Binary Envelope Items must have a length to be read'); + } + const bin = buffer.subarray(0, length); + // Replace the buffer with the remaining data excluding trailing newline + buffer = buffer.subarray(length + 1); + return bin; + } + + function readJson(): T { + let i = buffer.indexOf(0xa); + // If we couldn't find a newline, we must have found the end of the buffer + if (i < 0) { + i = buffer.length; + } + + return JSON.parse(decodeUTF8(readBinary(i))) as T; + } + + const envelopeHeader = readJson(); + + const items: [any, any][] = []; + + while (buffer.length) { + const itemHeader = readJson(); + const isBinaryAttachment = + itemHeader.type === 'attachment' && + itemHeader.content_type !== 'application/json'; + // TODO: Parse when needed for the tests + const isReplayVideo = (itemHeader.type as string) === 'replay_video'; + + try { + let item: any = {}; + if (isReplayVideo || isBinaryAttachment) { + item = readBinary(itemHeader.length); + } else { + item = readJson(); + } + items.push([itemHeader, item]); + } catch (e) { + console.error(e, 'itemHeader', itemHeader, 'buffer', buffer.toString()); + throw e; + } + } + + return [envelopeHeader, items]; +} + +/** + * Encode a string to UTF8 array. + */ +function encodeUTF8(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +/** + * Decode a UTF8 array to string. + */ +function decodeUTF8(input: Uint8Array): string { + return new TextDecoder().decode(input); +} diff --git a/samples/react-native/e2e/utils/tap.ts b/samples/react-native/e2e/utils/tap.ts new file mode 100644 index 0000000000..3b12d61e31 --- /dev/null +++ b/samples/react-native/e2e/utils/tap.ts @@ -0,0 +1,14 @@ +import { element, by } from 'detox'; + +export const tap = async (text: string) => { + await element(by.text(createFlexibleRegex(text))).tap(); +}; + +/** + * Creates regex that matches case insensitive and allows flexible spacing between words + */ +function createFlexibleRegex(input: string) { + const words = input.trim().split(/\s+/); + const pattern = words.join('\\s*'); + return new RegExp(pattern, 'i'); +} diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 128f329fa9..c2697d9959 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -25,6 +25,7 @@ "@react-navigation/native": "^7.0.3", "@react-navigation/native-stack": "^7.0.3", "@react-navigation/stack": "^7.0.3", + "@sentry/core": "8.54.0", "@sentry/react-native": "6.7.0-alpha.0", "delay": "^6.0.0", "react": "18.3.1", diff --git a/samples/react-native/scripts/dsn.mjs b/samples/react-native/scripts/dsn.mjs new file mode 100644 index 0000000000..da2153f203 --- /dev/null +++ b/samples/react-native/scripts/dsn.mjs @@ -0,0 +1,24 @@ +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function setIosDsn() { + setDsn('http://key@localhost:8961/123456'); +} + +export function setAndroidDsn() { + setDsn('http://key@10.0.2.2:8961/123456'); +} + +function setDsn(dsn) { + const sentryOptionsPath = path.join(__dirname, '../sentry.options.json'); + const sentryOptions = JSON.parse(fs.readFileSync(sentryOptionsPath, 'utf8')); + sentryOptions.dsn = dsn; + fs.writeFileSync( + sentryOptionsPath, + JSON.stringify(sentryOptions, null, 2) + '\n', + ); + console.log('Dsn set to: ', dsn); +} diff --git a/samples/react-native/scripts/set-aos-dsn.mjs b/samples/react-native/scripts/set-aos-dsn.mjs new file mode 100755 index 0000000000..521cb44a11 --- /dev/null +++ b/samples/react-native/scripts/set-aos-dsn.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { setAndroidDsn } from './dsn.mjs'; + +setAndroidDsn(); diff --git a/samples/react-native/scripts/set-ios-dsn.mjs b/samples/react-native/scripts/set-ios-dsn.mjs new file mode 100755 index 0000000000..ea1ca7e61a --- /dev/null +++ b/samples/react-native/scripts/set-ios-dsn.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { setIosDsn } from './dsn.mjs'; + +setIosDsn(); diff --git a/yarn.lock b/yarn.lock index ca53bb1707..91d20f2005 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24699,6 +24699,7 @@ __metadata: "@react-navigation/native-stack": ^7.0.3 "@react-navigation/stack": ^7.0.3 "@sentry/babel-plugin-component-annotate": ^2.18.0 + "@sentry/core": 8.54.0 "@sentry/react-native": 6.7.0-alpha.0 "@types/jest": ^29.5.14 "@types/node": ^22.13.1 From 0bf66360316a865361f4a8a06cf756a1c79a7462 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Thu, 20 Feb 2025 10:04:35 +0100 Subject: [PATCH 16/21] fix(sample-e2e): Fix type errors missing sentry/core and afterAll (#4564) --- samples/react-native/e2e/captureMessage.test.android.ts | 2 +- samples/react-native/e2e/captureMessage.test.ios.ts | 2 +- samples/react-native/e2e/envelopeHeader.test.android.ts | 2 +- samples/react-native/e2e/envelopeHeader.test.ios.ts | 2 +- samples/react-native/package.json | 1 + yarn.lock | 1 + 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/samples/react-native/e2e/captureMessage.test.android.ts b/samples/react-native/e2e/captureMessage.test.android.ts index a56d3704bc..ad6d298a02 100644 --- a/samples/react-native/e2e/captureMessage.test.android.ts +++ b/samples/react-native/e2e/captureMessage.test.android.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeAll, expect } from '@jest/globals'; +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; import { Envelope } from '@sentry/core'; import { device } from 'detox'; import { diff --git a/samples/react-native/e2e/captureMessage.test.ios.ts b/samples/react-native/e2e/captureMessage.test.ios.ts index 25c953b707..a379718a68 100644 --- a/samples/react-native/e2e/captureMessage.test.ios.ts +++ b/samples/react-native/e2e/captureMessage.test.ios.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeAll, expect } from '@jest/globals'; +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; import { Envelope } from '@sentry/core'; import { device } from 'detox'; import { diff --git a/samples/react-native/e2e/envelopeHeader.test.android.ts b/samples/react-native/e2e/envelopeHeader.test.android.ts index 26700f9245..4be175d6c9 100644 --- a/samples/react-native/e2e/envelopeHeader.test.android.ts +++ b/samples/react-native/e2e/envelopeHeader.test.android.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeAll, expect } from '@jest/globals'; +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; import { Envelope } from '@sentry/core'; import { device } from 'detox'; import { diff --git a/samples/react-native/e2e/envelopeHeader.test.ios.ts b/samples/react-native/e2e/envelopeHeader.test.ios.ts index 4b72f76e18..04ce534226 100644 --- a/samples/react-native/e2e/envelopeHeader.test.ios.ts +++ b/samples/react-native/e2e/envelopeHeader.test.ios.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeAll, expect } from '@jest/globals'; +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; import { Envelope } from '@sentry/core'; import { device } from 'detox'; import { diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 82e50a6755..b7b8980b88 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -53,6 +53,7 @@ "@react-native/metro-config": "0.77.0", "@react-native/typescript-config": "0.77.0", "@sentry/babel-plugin-component-annotate": "^3.1.2", + "@sentry/core": "8.54.0", "@types/react": "^18.2.65", "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "^18.0.0", diff --git a/yarn.lock b/yarn.lock index 17b6a36ac1..8dee18d805 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25067,6 +25067,7 @@ __metadata: "@react-navigation/native-stack": ^7.2.0 "@react-navigation/stack": ^7.1.1 "@sentry/babel-plugin-component-annotate": ^3.1.2 + "@sentry/core": 8.54.0 "@sentry/react-native": 6.7.0 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 From e935360ae40e6445f13a3c8a0150a739fd27112e Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Fri, 21 Feb 2025 08:29:27 +0100 Subject: [PATCH 17/21] chore(samples): Add package scripts for native builds, dsn and testing (#4561) --- .github/workflows/sample-application.yml | 72 ++++------ .../react-native-macos/scripts/pod-install.sh | 18 +++ samples/react-native/.detoxrc.js | 124 ++++-------------- samples/react-native/.gitignore | 2 + samples/react-native/package.json | 24 +++- .../scripts/build-android-debug-legacy.sh | 11 ++ .../scripts/build-android-debug.sh | 11 ++ .../scripts/build-android-release-legacy.sh | 11 ++ .../scripts/build-android-release.sh | 11 ++ samples/react-native/scripts/build-android.sh | 29 ++++ .../react-native/scripts/build-ios-debug.sh | 10 ++ .../react-native/scripts/build-ios-release.sh | 10 ++ samples/react-native/scripts/build-ios.sh | 30 +++++ .../pod-install-debug-dynamic-legacy.sh | 12 ++ .../scripts/pod-install-debug-dynamic.sh | 12 ++ .../pod-install-debug-static-legacy.sh | 12 ++ .../scripts/pod-install-debug-static.sh | 12 ++ .../pod-install-release-dynamic-legacy.sh | 12 ++ .../scripts/pod-install-release-dynamic.sh | 12 ++ .../pod-install-release-static-legacy.sh | 12 ++ .../scripts/pod-install-release-static.sh | 12 ++ samples/react-native/scripts/pod-install.sh | 18 +++ samples/react-native/scripts/set-aos-dsn.mjs | 5 - samples/react-native/scripts/set-dsn-aos.mjs | 5 + samples/react-native/scripts/set-dsn-ios.mjs | 5 + .../scripts/{dsn.mjs => set-dsn.mjs} | 0 samples/react-native/scripts/set-ios-dsn.mjs | 5 - samples/react-native/scripts/test-android.sh | 43 ++++++ samples/react-native/scripts/test-ios.sh | 24 ++++ samples/react-native/sentry.options.json | 8 +- 30 files changed, 401 insertions(+), 171 deletions(-) create mode 100755 samples/react-native-macos/scripts/pod-install.sh create mode 100755 samples/react-native/scripts/build-android-debug-legacy.sh create mode 100755 samples/react-native/scripts/build-android-debug.sh create mode 100755 samples/react-native/scripts/build-android-release-legacy.sh create mode 100755 samples/react-native/scripts/build-android-release.sh create mode 100755 samples/react-native/scripts/build-android.sh create mode 100755 samples/react-native/scripts/build-ios-debug.sh create mode 100755 samples/react-native/scripts/build-ios-release.sh create mode 100755 samples/react-native/scripts/build-ios.sh create mode 100755 samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh create mode 100755 samples/react-native/scripts/pod-install-debug-dynamic.sh create mode 100755 samples/react-native/scripts/pod-install-debug-static-legacy.sh create mode 100755 samples/react-native/scripts/pod-install-debug-static.sh create mode 100755 samples/react-native/scripts/pod-install-release-dynamic-legacy.sh create mode 100755 samples/react-native/scripts/pod-install-release-dynamic.sh create mode 100755 samples/react-native/scripts/pod-install-release-static-legacy.sh create mode 100755 samples/react-native/scripts/pod-install-release-static.sh create mode 100755 samples/react-native/scripts/pod-install.sh delete mode 100755 samples/react-native/scripts/set-aos-dsn.mjs create mode 100755 samples/react-native/scripts/set-dsn-aos.mjs create mode 100755 samples/react-native/scripts/set-dsn-ios.mjs rename samples/react-native/scripts/{dsn.mjs => set-dsn.mjs} (100%) delete mode 100755 samples/react-native/scripts/set-ios-dsn.mjs create mode 100755 samples/react-native/scripts/test-android.sh create mode 100755 samples/react-native/scripts/test-ios.sh diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 53202a0f31..603cafa619 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -99,66 +99,39 @@ jobs: if: ${{ matrix.platform == 'ios' || matrix.platform == 'macos' }} working-directory: samples run: | - [[ "${{ matrix.platform }}" == "ios" ]] && cd react-native/ios - [[ "${{ matrix.platform }}" == "macos" ]] && cd react-native-macos/macos + [[ "${{ matrix.platform }}" == "ios" ]] && cd react-native + [[ "${{ matrix.platform }}" == "macos" ]] && cd react-native-macos - [[ "${{ matrix.build-type }}" == "production" ]] && ENABLE_PROD=1 || ENABLE_PROD=0 - [[ "${{ matrix.rn-architecture }}" == "new" ]] && ENABLE_NEW_ARCH=1 || ENABLE_NEW_ARCH=0 + [[ "${{ matrix.build-type }}" == "production" ]] && export ENABLE_PROD=1 || export ENABLE_PROD=0 + [[ "${{ matrix.rn-architecture }}" == "new" ]] && export ENABLE_NEW_ARCH=1 || export ENABLE_NEW_ARCH=0 [[ "${{ matrix.ios-use-frameworks }}" == "dynamic-frameworks" ]] && export USE_FRAMEWORKS=dynamic - echo "ENABLE_PROD=$ENABLE_PROD" - echo "ENABLE_NEW_ARCH=$ENABLE_NEW_ARCH" - PRODUCTION=$ENABLE_PROD RCT_NEW_ARCH_ENABLED=$ENABLE_NEW_ARCH bundle exec pod install - cat Podfile.lock | grep $RN_SENTRY_POD_NAME + + ./scripts/pod-install.sh - name: Build Android App if: ${{ matrix.platform == 'android' }} - working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} run: | - ../scripts/set-aos-dsn.mjs - - if [[ ${{ matrix.rn-architecture }} == 'new' ]]; then - perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties - echo 'New Architecture enabled' - elif [[ ${{ matrix.rn-architecture }} == 'legacy' ]]; then - perl -i -pe's/newArchEnabled=true/newArchEnabled=false/g' gradle.properties - echo 'Legacy Architecture enabled' - else - echo 'No changes for architecture: ${{ matrix.rn-architecture }}' - fi - [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' - echo "Building $CONFIG" - [[ "${{ matrix.build-type }}" == "production" ]] && TEST_TYPE='release' || TEST_TYPE='debug' - echo "Building $TEST_TYPE" + export RN_ARCHITECTURE="${{ matrix.rn-architecture }}" + [[ "${{ matrix.build-type }}" == "production" ]] && export CONFIG='release' || export CONFIG='debug' - ./gradlew ":app:assemble$CONFIG" app:assembleAndroidTest -DtestBuildType=$TEST_TYPE -PreactNativeArchitectures=x86 + ./scripts/set-dsn-aos.mjs + ./scripts/build-android.sh -PreactNativeArchitectures=x86 - name: Build iOS App if: ${{ matrix.platform == 'ios' }} - working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} run: | - ../scripts/set-ios-dsn.mjs + [[ "${{ matrix.build-type }}" == "production" ]] && export CONFIG='Release' || export CONFIG='Debug' - [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' - echo "Building $CONFIG" - mkdir -p "DerivedData" - derivedData="$(cd "DerivedData" ; pwd -P)" - set -o pipefail && xcodebuild \ - -workspace sentryreactnativesample.xcworkspace \ - -configuration "$CONFIG" \ - -scheme sentryreactnativesample \ - -sdk 'iphonesimulator' \ - -destination 'generic/platform=iOS Simulator' \ - ONLY_ACTIVE_ARCH=yes \ - -derivedDataPath "$derivedData" \ - build \ - | tee xcodebuild.log \ - | xcbeautify --quieter --is-ci --disable-colored-output + ./scripts/set-dsn-ios.mjs + ./scripts/build-ios.sh - name: Build macOS App if: ${{ matrix.platform == 'macos' }} working-directory: samples/react-native-macos/macos run: | - [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' + [[ "${{ matrix.build-type }}" == "production" ]] && export CONFIG='Release' || export CONFIG='Debug' echo "Building $CONFIG" mkdir -p "DerivedData" derivedData="$(cd "DerivedData" ; pwd -P)" @@ -175,8 +148,8 @@ jobs: - name: Archive iOS App if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} run: | - cd ${{ env.REACT_NATIVE_SAMPLE_PATH }}/ios/DerivedData/Build/Products/Release-iphonesimulator zip -r \ ${{ github.workspace }}/${{ env.IOS_APP_ARCHIVE_PATH }} \ sentryreactnativesample.app @@ -184,12 +157,10 @@ jobs: - name: Archive Android App if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} run: | - mv ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android/app/build/outputs/apk/release/app-release.apk app.apk - mv ${{ env.REACT_NATIVE_SAMPLE_PATH }}/android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk app-androidTest.apk zip -j \ ${{ env.ANDROID_APP_ARCHIVE_PATH }} \ - app.apk \ - app-androidTest.apk + ${{ env.REACT_NATIVE_SAMPLE_PATH }}/app.apk \ + ${{ env.REACT_NATIVE_SAMPLE_PATH }}/app-androidTest.apk - name: Upload iOS APP if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} @@ -306,13 +277,14 @@ jobs: - name: Run Detox iOS Tests if: ${{ matrix.platform == 'ios' }} working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - run: detox test --configuration ci.sim + run: yarn test-ios - name: Run tests on Android if: ${{ matrix.platform == 'android' }} env: # used by Detox ci.android configuration ANDROID_AVD_NAME: 'test' # test is default reactivecircus/android-emulator-runner name + ANDROID_TYPE: 'android.emulator' uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 with: api-level: ${{ env.ANDROID_API_LEVEL }} @@ -331,4 +303,4 @@ jobs: -camera-front none -timezone US/Pacific working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - script: detox test --configuration ci.android + script: yarn test-android diff --git a/samples/react-native-macos/scripts/pod-install.sh b/samples/react-native-macos/scripts/pod-install.sh new file mode 100755 index 0000000000..a923f8c32a --- /dev/null +++ b/samples/react-native-macos/scripts/pod-install.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +echo "USE_FRAMEWORKS=$USE_FRAMEWORKS" +echo "ENABLE_PROD=$ENABLE_PROD" +echo "ENABLE_NEW_ARCH=$ENABLE_NEW_ARCH" + +cd "${thisFilePath}/.." +bundle install + +cd macos +PRODUCTION=$ENABLE_PROD RCT_NEW_ARCH_ENABLED=$ENABLE_NEW_ARCH bundle exec pod update + +cat Podfile.lock | grep $RN_SENTRY_POD_NAME diff --git a/samples/react-native/.detoxrc.js b/samples/react-native/.detoxrc.js index e6ba0d66cd..11c535ba65 100644 --- a/samples/react-native/.detoxrc.js +++ b/samples/react-native/.detoxrc.js @@ -1,56 +1,9 @@ const process = require('process'); -const testRunnerIos = { - args: { - $0: 'jest', - config: 'e2e/jest.config.ios.js', - }, - jest: { - setupTimeout: 120000, - }, -}; - -const testRunnerAos = { - args: { - $0: 'jest', - config: 'e2e/jest.config.android.js', - }, - jest: { - setupTimeout: 120000, - }, -}; - /** @type {Detox.DetoxConfig} */ module.exports = { testRunner: {}, apps: { - 'ios.debug': { - type: 'ios.app', - binaryPath: - 'ios/build/Build/Products/Debug-iphonesimulator/sentryreactnativesample.app', - build: - 'xcodebuild -workspace ios/sentryreactnativesample.xcworkspace -scheme sentryreactnativesample -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', - }, - 'ios.release': { - type: 'ios.app', - binaryPath: - 'ios/build/Build/Products/Release-iphonesimulator/sentryreactnativesample.app', - build: - 'xcodebuild -workspace ios/sentryreactnativesample.xcworkspace -scheme sentryreactnativesample -configuration Release -sdk iphonesimulator -derivedDataPath ios/build', - }, - 'android.debug': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', - build: - 'cd android && ./gradlew app:assembleDebug app:assembleAndroidTest -DtestBuildType=debug', - reversePorts: [8081], - }, - 'android.release': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', - build: - 'cd android && ./gradlew app:assembleRelease app:assembleAndroidTest -DtestBuildType=release', - }, 'ci.android': { type: 'android.apk', binaryPath: 'app.apk', @@ -62,78 +15,47 @@ module.exports = { }, }, devices: { - simulator: { - type: 'ios.simulator', - device: { - type: 'iPhone 16', - }, - }, - attached: { - type: 'android.attached', - device: { - adbName: '.*', - }, - }, - emulator: { - type: 'android.emulator', - device: { - avdName: 'Pixel_9_API_35', - }, - }, 'ci.emulator': { - type: 'android.emulator', + type: process.env.ANDROID_TYPE?.trim(), device: { - avdName: process.env.ANDROID_AVD_NAME, + avdName: process.env.ANDROID_AVD_NAME?.trim(), + adbName: process.env.ANDROID_ADB_NAME?.trim(), }, }, 'ci.simulator': { type: 'ios.simulator', device: { - type: process.env.IOS_DEVICE, - os: process.env.IOS_VERSION, + type: process.env.IOS_DEVICE?.trim(), + os: process.env.IOS_VERSION?.trim(), }, }, }, configurations: { - 'ios.sim.debug': { - device: 'simulator', - app: 'ios.debug', - testRunner: testRunnerIos, - }, - 'ios.sim.release': { - device: 'simulator', - app: 'ios.release', - testRunner: testRunnerIos, - }, - 'android.att.debug': { - device: 'attached', - app: 'android.debug', - testRunner: testRunnerAos, - }, - 'android.att.release': { - device: 'attached', - app: 'android.release', - testRunner: testRunnerAos, - }, - 'android.emu.debug': { - device: 'emulator', - app: 'android.debug', - testRunner: testRunnerAos, - }, - 'android.emu.release': { - device: 'emulator', - app: 'android.release', - testRunner: testRunnerAos, - }, 'ci.android': { device: 'ci.emulator', app: 'ci.android', - testRunner: testRunnerAos, + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.android.js', + }, + jest: { + setupTimeout: 120000, + }, + }, }, 'ci.sim': { device: 'ci.simulator', app: 'ci.ios', - testRunner: testRunnerIos, + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.ios.js', + }, + jest: { + setupTimeout: 120000, + }, + }, }, }, }; diff --git a/samples/react-native/.gitignore b/samples/react-native/.gitignore index 40824c4232..9eb5c6ab12 100644 --- a/samples/react-native/.gitignore +++ b/samples/react-native/.gitignore @@ -63,3 +63,5 @@ yarn-error.log .metro-health-check* *.xcarchive +*.apk +**/*.app diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 3ba6ff8eb8..7cc3fecef9 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -4,16 +4,28 @@ "private": true, "scripts": { "postinstall": "patch-package", - "android": "react-native run-android", - "ios": "react-native run-ios", "start": "react-native start", + "build-android-release": "scripts/build-android-release.sh", + "build-android-release-legacy": "scripts/build-android-release-legacy.sh", + "build-android-debug": "scripts/build-android-debug.sh", + "build-android-debug-legacy": "scripts/build-android-debug-legacy.sh", + "build-ios-release": "scripts/build-ios-release.sh", + "build-ios-debug": "scripts/build-ios-debug.sh", "test": "jest", + "set-test-dsn-android": "scripts/set-dsn-aos.mjs", + "set-test-dsn-ios": "scripts/set-dsn-ios.mjs", + "test-android": "scripts/test-android.sh", + "test-ios": "scripts/test-ios.sh", "lint": "npx eslint . --ext .js,.jsx,.ts,.tsx", "fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", - "pod-install": "cd ios; RCT_NEW_ARCH_ENABLED=1 bundle exec pod install; cd ..", - "pod-install-production": "cd ios; PRODUCTION=1 RCT_NEW_ARCH_ENABLED=1 bundle exec pod install; cd ..", - "pod-install-legacy": "cd ios; bundle exec pod install; cd ..", - "pod-install-legacy-production": "cd ios; PRODUCTION=1 bundle exec pod install; cd ..", + "pod-install-debug-static": "scripts/pod-install-debug-static.sh", + "pod-install-debug-static-legacy": "scripts/pod-install-debug-static-legacy.sh", + "pod-install-debug-dynamic": "scripts/pod-install-debug-dynamic.sh", + "pod-install-debug-dynamic-legacy": "scripts/pod-install-debug-dynamic-legacy.sh", + "pod-install-release-static": "scripts/pod-install-release-static.sh", + "pod-install-release-static-legacy": "scripts/pod-install-release-static-legacy.sh", + "pod-install-release-dynamic": "scripts/pod-install-release-dynamic.sh", + "pod-install-release-dynamic-legacy": "scripts/pod-install-release-dynamic-legacy.sh", "clean-ios": "cd ios; rm -rf Podfile.lock Pods build; cd ..", "clean-watchman": "watchman watch-del-all", "set-build-number": "npx react-native-version --skip-tag --never-amend --set-build", diff --git a/samples/react-native/scripts/build-android-debug-legacy.sh b/samples/react-native/scripts/build-android-debug-legacy.sh new file mode 100755 index 0000000000..ac0952892d --- /dev/null +++ b/samples/react-native/scripts/build-android-debug-legacy.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="legacy" +export CONFIG="debug" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android-debug.sh b/samples/react-native/scripts/build-android-debug.sh new file mode 100755 index 0000000000..89f9ae626c --- /dev/null +++ b/samples/react-native/scripts/build-android-debug.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="debug" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android-release-legacy.sh b/samples/react-native/scripts/build-android-release-legacy.sh new file mode 100755 index 0000000000..cf853c15cc --- /dev/null +++ b/samples/react-native/scripts/build-android-release-legacy.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="legacy" +export CONFIG="release" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android-release.sh b/samples/react-native/scripts/build-android-release.sh new file mode 100755 index 0000000000..3403a3c1bb --- /dev/null +++ b/samples/react-native/scripts/build-android-release.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="release" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android.sh b/samples/react-native/scripts/build-android.sh new file mode 100755 index 0000000000..866b5cc130 --- /dev/null +++ b/samples/react-native/scripts/build-android.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/../android" + +rm -rf ../app.apk ../app-androidTest.apk + +if [[ "${RN_ARCHITECTURE}" == 'new' ]]; then + perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties + echo 'New Architecture enabled' +elif [[ "${RN_ARCHITECTURE}" == 'legacy' ]]; then + perl -i -pe's/newArchEnabled=true/newArchEnabled=false/g' gradle.properties + echo 'Legacy Architecture enabled' +else + echo "No changes for architecture: ${RN_ARCHITECTURE}" +fi + +echo "Building $CONFIG" + +assembleConfig=$(python -c "print(\"${CONFIG}\".capitalize())") + +./gradlew ":app:assemble${assembleConfig}" app:assembleAndroidTest -DtestBuildType=$CONFIG "$@" + +cp "app/build/outputs/apk/${CONFIG}/app-${CONFIG}.apk" ../app.apk +cp "app/build/outputs/apk/androidTest/${CONFIG}/app-${CONFIG}-androidTest.apk" ../app-androidTest.apk diff --git a/samples/react-native/scripts/build-ios-debug.sh b/samples/react-native/scripts/build-ios-debug.sh new file mode 100755 index 0000000000..088ed50f28 --- /dev/null +++ b/samples/react-native/scripts/build-ios-debug.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +export CONFIG='Debug' + +"${thisFilePath}/build-ios.sh" diff --git a/samples/react-native/scripts/build-ios-release.sh b/samples/react-native/scripts/build-ios-release.sh new file mode 100755 index 0000000000..4a21d04c17 --- /dev/null +++ b/samples/react-native/scripts/build-ios-release.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +export CONFIG='Release' + +"${thisFilePath}/build-ios.sh" diff --git a/samples/react-native/scripts/build-ios.sh b/samples/react-native/scripts/build-ios.sh new file mode 100755 index 0000000000..7897aba332 --- /dev/null +++ b/samples/react-native/scripts/build-ios.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/../ios" + +rm -rf ../sentryreactnativesample.app + +echo "Building $CONFIG" + +rm -rf xcodebuild.log + +mkdir -p "DerivedData" +derivedData="$(cd "DerivedData" ; pwd -P)" +set -o pipefail && xcodebuild \ + -workspace sentryreactnativesample.xcworkspace \ + -configuration "$CONFIG" \ + -scheme sentryreactnativesample \ + -sdk 'iphonesimulator' \ + -destination 'generic/platform=iOS Simulator' \ + ONLY_ACTIVE_ARCH=yes \ + -derivedDataPath "$derivedData" \ + build \ + | tee xcodebuild.log \ + | if [ "$CI" = "true" ]; then xcbeautify --quieter --is-ci --disable-colored-output; else xcbeautify; fi + +cp -r "DerivedData/Build/Products/${CONFIG}-iphonesimulator/sentryreactnativesample.app" ../sentryreactnativesample.app diff --git a/samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh b/samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh new file mode 100755 index 0000000000..cea9690bb0 --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-debug-dynamic.sh b/samples/react-native/scripts/pod-install-debug-dynamic.sh new file mode 100755 index 0000000000..ed3acafb8b --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-dynamic.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-debug-static-legacy.sh b/samples/react-native/scripts/pod-install-debug-static-legacy.sh new file mode 100755 index 0000000000..52b80ba450 --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-static-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-debug-static.sh b/samples/react-native/scripts/pod-install-debug-static.sh new file mode 100755 index 0000000000..86049e4425 --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-static.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-dynamic-legacy.sh b/samples/react-native/scripts/pod-install-release-dynamic-legacy.sh new file mode 100755 index 0000000000..d6f7449abc --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-dynamic-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-dynamic.sh b/samples/react-native/scripts/pod-install-release-dynamic.sh new file mode 100755 index 0000000000..9207b45dfe --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-dynamic.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-static-legacy.sh b/samples/react-native/scripts/pod-install-release-static-legacy.sh new file mode 100755 index 0000000000..9742caa73a --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-static-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-static.sh b/samples/react-native/scripts/pod-install-release-static.sh new file mode 100755 index 0000000000..8de5b13a61 --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-static.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install.sh b/samples/react-native/scripts/pod-install.sh new file mode 100755 index 0000000000..5d1ada6789 --- /dev/null +++ b/samples/react-native/scripts/pod-install.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +echo "USE_FRAMEWORKS=$USE_FRAMEWORKS" +echo "ENABLE_PROD=$ENABLE_PROD" +echo "ENABLE_NEW_ARCH=$ENABLE_NEW_ARCH" + +cd "${thisFilePath}/.." +bundle install + +cd ios +PRODUCTION=$ENABLE_PROD RCT_NEW_ARCH_ENABLED=$ENABLE_NEW_ARCH bundle exec pod update + +cat Podfile.lock | grep $RN_SENTRY_POD_NAME diff --git a/samples/react-native/scripts/set-aos-dsn.mjs b/samples/react-native/scripts/set-aos-dsn.mjs deleted file mode 100755 index 521cb44a11..0000000000 --- a/samples/react-native/scripts/set-aos-dsn.mjs +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node - -import { setAndroidDsn } from './dsn.mjs'; - -setAndroidDsn(); diff --git a/samples/react-native/scripts/set-dsn-aos.mjs b/samples/react-native/scripts/set-dsn-aos.mjs new file mode 100755 index 0000000000..01bed2c984 --- /dev/null +++ b/samples/react-native/scripts/set-dsn-aos.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { setAndroidDsn } from './set-dsn.mjs'; + +setAndroidDsn(); diff --git a/samples/react-native/scripts/set-dsn-ios.mjs b/samples/react-native/scripts/set-dsn-ios.mjs new file mode 100755 index 0000000000..7757c1e7b7 --- /dev/null +++ b/samples/react-native/scripts/set-dsn-ios.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { setIosDsn } from './set-dsn.mjs'; + +setIosDsn(); diff --git a/samples/react-native/scripts/dsn.mjs b/samples/react-native/scripts/set-dsn.mjs similarity index 100% rename from samples/react-native/scripts/dsn.mjs rename to samples/react-native/scripts/set-dsn.mjs diff --git a/samples/react-native/scripts/set-ios-dsn.mjs b/samples/react-native/scripts/set-ios-dsn.mjs deleted file mode 100755 index ea1ca7e61a..0000000000 --- a/samples/react-native/scripts/set-ios-dsn.mjs +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node - -import { setIosDsn } from './dsn.mjs'; - -setIosDsn(); diff --git a/samples/react-native/scripts/test-android.sh b/samples/react-native/scripts/test-android.sh new file mode 100755 index 0000000000..bbef32a81f --- /dev/null +++ b/samples/react-native/scripts/test-android.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/.." + +if [ -z "$ANDROID_AVD_NAME" ]; then + # Get the name of the first booted or connected Android device + DEVICE_NAME=$(adb devices | grep -w "device" | head -n 1 | cut -f 1) + + if [ -z "$DEVICE_NAME" ]; then + echo "No Android device or emulator found" + exit 1 + fi + + if [[ "$DEVICE_NAME" == *"emulator"* ]]; then + # Get the name of the first booted or connected Android emulator/device + EMULATOR_NAME=$(adb -s "${DEVICE_NAME}" emu avd name | head -n 1 | cut -f 1 ) + + if [ -z "$EMULATOR_NAME" ]; then + echo "No Android emulator found" + exit 1 + fi + + export ANDROID_TYPE="android.emulator" + export ANDROID_AVD_NAME="$EMULATOR_NAME" + echo "Using Android emulator: $EMULATOR_NAME" + else + export ANDROID_TYPE="android.attached" + export ANDROID_ADB_NAME="$DEVICE_NAME" + + adb reverse tcp:8081 tcp:8081 + adb reverse tcp:8961 tcp:8961 + + echo "Using Android device: $DEVICE_NAME" + fi +fi + +# Run the tests +detox test --configuration ci.android diff --git a/samples/react-native/scripts/test-ios.sh b/samples/react-native/scripts/test-ios.sh new file mode 100755 index 0000000000..ee9b1bb83f --- /dev/null +++ b/samples/react-native/scripts/test-ios.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/.." + +if [ -z "$IOS_DEVICE" ]; then + # Get the first booted simulator device type and version + BOOTED_DEVICE=$(xcrun simctl list devices | grep "Booted" | head -n 1) + + if [ -z "$BOOTED_DEVICE" ]; then + echo "No booted iOS simulator found" + exit 1 + fi + + # Extract device type from booted device + export IOS_DEVICE=$(echo "$BOOTED_DEVICE" | cut -d "(" -f1 | xargs) + echo "Using booted iOS simulator: $IOS_DEVICE" +fi + +detox test --configuration ci.sim diff --git a/samples/react-native/sentry.options.json b/samples/react-native/sentry.options.json index 53ae525bc0..58425c35d5 100644 --- a/samples/react-native/sentry.options.json +++ b/samples/react-native/sentry.options.json @@ -6,13 +6,13 @@ "enableAutoSessionTracking": true, "sessionTrackingIntervalMillis": 30000, "enableTracing": true, - "tracesSampleRate": 1.0, + "tracesSampleRate": 1, "attachStacktrace": true, "attachScreenshot": true, "attachViewHierarchy": true, "enableCaptureFailedRequests": true, - "profilesSampleRate": 1.0, - "replaysSessionSampleRate": 1.0, - "replaysOnErrorSampleRate": 1.0, + "profilesSampleRate": 1, + "replaysSessionSampleRate": 1, + "replaysOnErrorSampleRate": 1, "spotlight": true } From ae342a3a07de4a5e94f2658b5e3d1a4b8792967a Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:46:16 +0100 Subject: [PATCH 18/21] test(e2e): Verify captured Errors Screen transaction (#4584) --- .../e2e/captureMessage.test.android.ts | 107 ++++++++- .../e2e/captureMessage.test.ios.ts | 90 +++++++- .../e2e/captureTransaction.test.ts | 205 ++++++++++++++++++ samples/react-native/e2e/utils/event.ts | 11 + .../e2e/utils/mockedSentryServer.ts | 46 +++- samples/react-native/e2e/utils/sleep.ts | 3 + 6 files changed, 448 insertions(+), 14 deletions(-) create mode 100644 samples/react-native/e2e/captureTransaction.test.ts create mode 100644 samples/react-native/e2e/utils/event.ts create mode 100644 samples/react-native/e2e/utils/sleep.ts diff --git a/samples/react-native/e2e/captureMessage.test.android.ts b/samples/react-native/e2e/captureMessage.test.android.ts index ad6d298a02..d08473ac2b 100644 --- a/samples/react-native/e2e/captureMessage.test.android.ts +++ b/samples/react-native/e2e/captureMessage.test.android.ts @@ -1,12 +1,12 @@ import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; -import { Envelope } from '@sentry/core'; +import { Envelope, EventItem } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, containingEvent, } from './utils/mockedSentryServer'; -import { HEADER, ITEMS } from './utils/consts'; import { tap } from './utils/tap'; +import { getItemOfTypeFrom } from './utils/event'; describe('Capture message', () => { let sentryServer = createSentryServer(); @@ -27,9 +27,7 @@ describe('Capture message', () => { }); it('envelope contains message event', async () => { - const item = (envelope[ITEMS] as [{ type?: string }, unknown][]).find( - i => i[HEADER].type === 'event', - ); + const item = getItemOfTypeFrom(envelope, 'event'); expect(item).toEqual([ { @@ -46,4 +44,103 @@ describe('Capture message', () => { }), ]); }); + + it('contains device context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + battery_level: expect.any(Number), + battery_temperature: expect.any(Number), + boot_time: expect.any(String), + brand: expect.any(String), + charging: expect.any(Boolean), + connection_type: expect.any(String), + family: expect.any(String), + free_memory: expect.any(Number), + free_storage: expect.any(Number), + id: expect.any(String), + language: expect.any(String), + locale: expect.any(String), + low_memory: expect.any(Boolean), + manufacturer: expect.any(String), + memory_size: expect.any(Number), + model: expect.any(String), + model_id: expect.any(String), + online: expect.any(Boolean), + orientation: expect.any(String), + processor_count: expect.any(Number), + processor_frequency: expect.any(Number), + screen_density: expect.any(Number), + screen_dpi: expect.any(Number), + screen_height_pixels: expect.any(Number), + screen_width_pixels: expect.any(Number), + simulator: expect.any(Boolean), + storage_size: expect.any(Number), + timezone: expect.any(String), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_build: expect.any(String), + app_identifier: expect.any(String), + app_name: expect.any(String), + app_start_time: expect.any(String), + app_version: expect.any(String), + in_foreground: expect.any(Boolean), + view_names: ['ErrorsScreen'], + }), + }), + }), + ); + }); + + it('contains os context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + os: { + build: expect.any(String), + kernel_version: expect.any(String), + name: 'Android', + rooted: expect.any(Boolean), + version: expect.any(String), + }, + }), + }), + ); + }); + + it('contains react native context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + react_native_context: { + expo: false, + fabric: expect.any(Boolean), + hermes_debug_info: expect.any(Boolean), + hermes_version: expect.any(String), + js_engine: 'hermes', + react_native_version: expect.any(String), + turbo_module: expect.any(Boolean), + }, + }), + }), + ); + }); }); diff --git a/samples/react-native/e2e/captureMessage.test.ios.ts b/samples/react-native/e2e/captureMessage.test.ios.ts index a379718a68..9e3a804881 100644 --- a/samples/react-native/e2e/captureMessage.test.ios.ts +++ b/samples/react-native/e2e/captureMessage.test.ios.ts @@ -1,12 +1,12 @@ import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; -import { Envelope } from '@sentry/core'; +import { Envelope, EventItem } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, containingEvent, } from './utils/mockedSentryServer'; -import { HEADER, ITEMS } from './utils/consts'; import { tap } from './utils/tap'; +import { getItemOfTypeFrom } from './utils/event'; describe('Capture message', () => { let sentryServer = createSentryServer(); @@ -27,9 +27,7 @@ describe('Capture message', () => { }); it('envelope contains message event', async () => { - const item = (envelope[ITEMS] as [{ type?: string }, unknown][]).find( - i => i[HEADER].type === 'event', - ); + const item = getItemOfTypeFrom(envelope, 'event'); expect(item).toEqual([ { @@ -43,4 +41,86 @@ describe('Capture message', () => { }), ]); }); + + it('contains device context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + arch: expect.any(String), + family: expect.any(String), + free_memory: expect.any(Number), + locale: expect.any(String), + memory_size: expect.any(Number), + model: expect.any(String), + model_id: expect.any(String), + processor_count: expect.any(Number), + simulator: expect.any(Boolean), + thermal_state: expect.any(String), + usable_memory: expect.any(Number), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_build: expect.any(String), + app_identifier: expect.any(String), + app_name: expect.any(String), + app_start_time: expect.any(String), + app_version: expect.any(String), + in_foreground: expect.any(Boolean), + // view_names: ['ErrorsScreen-jn5qquvH9Nz'], // TODO: fix this generated hash should not be part of the name + }), + }), + }), + ); + }); + + it('contains os context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + os: { + build: expect.any(String), + kernel_version: expect.any(String), + name: 'iOS', + rooted: expect.any(Boolean), + version: expect.any(String), + }, + }), + }), + ); + }); + + it('contains react native context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + react_native_context: { + expo: false, + fabric: expect.any(Boolean), + hermes_debug_info: expect.any(Boolean), + hermes_version: expect.any(String), + js_engine: 'hermes', + react_native_version: expect.any(String), + turbo_module: expect.any(Boolean), + }, + }), + }), + ); + }); }); diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts new file mode 100644 index 0000000000..95d109c4e4 --- /dev/null +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -0,0 +1,205 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingTransactionWithName, +} from './utils/mockedSentryServer'; +import { tap } from './utils/tap'; +import { sleep } from './utils/sleep'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture transaction', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + const getErrorsEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Errors')); + + const getTrackerEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Tracker')); + + beforeAll(async () => { + await device.launchApp(); + + const waitForPerformanceTransaction = sentryServer.waitForEnvelope( + containingTransactionWithName('Tracker'), // The last created and sent transaction + ); + + await sleep(500); + await tap('Performance'); // Bottom tab + await sleep(200); + await tap('Auto Tracing Example'); // Screen with Full Display + + await waitForPerformanceTransaction; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains transaction context', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect(item).toEqual([ + expect.objectContaining({ + length: expect.any(Number), + type: 'transaction', + }), + expect.objectContaining({ + platform: 'javascript', + transaction: 'ErrorsScreen', + contexts: expect.objectContaining({ + trace: { + data: { + 'route.has_been_seen': false, + 'route.key': expect.stringMatching(/^ErrorsScreen/), + 'route.name': 'ErrorsScreen', + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'ui.load', + 'sentry.origin': 'auto.app.start', + 'sentry.sample_rate': 1, + 'sentry.source': 'component', + }, + op: 'ui.load', + origin: 'auto.app.start', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + }), + ]); + }); + + it('contains app start measurements', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect( + item?.[1].measurements?.app_start_warm || + item?.[1].measurements?.app_start_cold, + ).toBeDefined(); + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + // Expect warm or cold app start measurements + ...(item?.[1].measurements?.app_start_warm && { + app_start_warm: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + ...(item?.[1].measurements?.app_start_cold && { + app_start_cold: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + }), + ); + }); + + it('contains time to initial display measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains JS stall measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + stall_count: { + unit: 'none', + value: expect.any(Number), + }, + stall_longest_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + stall_total_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains time to display measurements', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + time_to_full_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: 'xhr', + data: { + end_timestamp: expect.any(Number), + method: 'GET', + response_body_size: expect.any(Number), + start_timestamp: expect.any(Number), + status_code: expect.any(Number), + url: expect.stringContaining('api.covid19api.com/summary'), + }, + level: 'info', + timestamp: expect.any(Number), + type: 'http', + }), + ]), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/utils/event.ts b/samples/react-native/e2e/utils/event.ts new file mode 100644 index 0000000000..df631feb4e --- /dev/null +++ b/samples/react-native/e2e/utils/event.ts @@ -0,0 +1,11 @@ +import { Envelope, EnvelopeItem } from '@sentry/core'; +import { HEADER, ITEMS } from './consts'; + +export function getItemOfTypeFrom( + envelope: Envelope, + type: string, +): T | undefined { + return (envelope[ITEMS] as [{ type?: string }, unknown][]).find( + i => i[HEADER].type === type, + ) as T | undefined; +} diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts index 40667b0f9d..7f19a0d24b 100644 --- a/samples/react-native/e2e/utils/mockedSentryServer.ts +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -1,7 +1,8 @@ import { IncomingMessage, ServerResponse, createServer } from 'node:http'; import { createGunzip } from 'node:zlib'; -import { Envelope } from '@sentry/core'; +import { Envelope, EnvelopeItem } from '@sentry/core'; import { parseEnvelope } from './parseEnvelope'; +import { Event } from '@sentry/core'; type RecordedRequest = { path: string | undefined; @@ -16,6 +17,7 @@ export function createSentryServer({ port = 8961 } = {}): { ) => Promise; close: () => Promise; start: () => void; + getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope; } { let onNextRequestCallback: (request: RecordedRequest) => void = () => {}; const requests: RecordedRequest[] = []; @@ -74,11 +76,47 @@ export function createSentryServer({ port = 8961 } = {}): { server.close(() => resolve()); }); }, + getEnvelope: (predicate: (envelope: Envelope) => boolean) => { + const envelope = requests.find( + request => request.envelope && predicate(request.envelope), + )?.envelope; + + if (!envelope) { + throw new Error('Envelope not found'); + } + + return envelope; + }, }; } export function containingEvent(envelope: Envelope) { - return envelope[1].some( - item => (item[0] as { type?: string }).type === 'event', - ); + return envelope[1].some(item => itemHeaderIsType(item[0], 'event')); +} + +export function containingTransactionWithName(name: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'transaction') && + itemBodyIsEvent(item[1]) && + item[1].transaction && + item[1].transaction.includes(name), + ); +} + +export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event { + return typeof itemBody === 'object' && 'event_id' in itemBody; +} + +export function itemHeaderIsType(itemHeader: EnvelopeItem[0], type: string) { + if (typeof itemHeader !== 'object' || !('type' in itemHeader)) { + return false; + } + + if (itemHeader.type !== type) { + return false; + } + + return true; } diff --git a/samples/react-native/e2e/utils/sleep.ts b/samples/react-native/e2e/utils/sleep.ts new file mode 100644 index 0000000000..a3b7734163 --- /dev/null +++ b/samples/react-native/e2e/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} From 770f9fb9f1646c97e7b356bb7b05c75dc3d947b4 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:25:24 +0100 Subject: [PATCH 19/21] test(e2e): Add auto init from JS tests (#4588) --- .github/workflows/sample-application.yml | 22 ++++++++++++++----- samples/react-native/package.json | 1 + .../react-native/scripts/detect-ios-sim.sh | 18 +++++++++++++++ samples/react-native/scripts/test-ios-auto.sh | 12 ++++++++++ samples/react-native/scripts/test-ios.sh | 14 +----------- 5 files changed, 49 insertions(+), 18 deletions(-) create mode 100755 samples/react-native/scripts/detect-ios-sim.sh create mode 100755 samples/react-native/scripts/test-ios-auto.sh diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 603cafa619..3bfa290fe3 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -186,7 +186,7 @@ jobs: path: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/${{ matrix.platform }}/*.log test: - name: Test ${{ matrix.platform }} ${{ matrix.build-type }} + name: ${{ matrix.job-name }} runs-on: ${{ matrix.runs-on }} needs: [diff_check, build] if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} @@ -195,16 +195,28 @@ jobs: fail-fast: false matrix: include: - - platform: ios + - job-name: 'Test iOS Release Auto Init' + platform: ios runs-on: macos-15 rn-architecture: 'new' ios-use-frameworks: 'no-frameworks' build-type: 'production' + test-command: 'yarn test-ios-auto' # tests native auto init from JS - - platform: android + - job-name: 'Test iOS Release Manual Init' + platform: ios + runs-on: macos-15 + rn-architecture: 'new' + ios-use-frameworks: 'no-frameworks' + build-type: 'production' + test-command: 'yarn test-ios' + + - job-name: 'Test Android Release Manual Init' + platform: android runs-on: ubuntu-latest rn-architecture: 'new' build-type: 'production' + test-command: 'yarn test-android' steps: - uses: actions/checkout@v4 @@ -277,7 +289,7 @@ jobs: - name: Run Detox iOS Tests if: ${{ matrix.platform == 'ios' }} working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - run: yarn test-ios + run: ${{ matrix.test-command }} - name: Run tests on Android if: ${{ matrix.platform == 'android' }} @@ -303,4 +315,4 @@ jobs: -camera-front none -timezone US/Pacific working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - script: yarn test-android + script: ${{ matrix.test-command }} diff --git a/samples/react-native/package.json b/samples/react-native/package.json index fb1ddbcd67..a5b36afd9d 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -16,6 +16,7 @@ "set-test-dsn-ios": "scripts/set-dsn-ios.mjs", "test-android": "scripts/test-android.sh", "test-ios": "scripts/test-ios.sh", + "test-ios-auto": "scripts/test-ios-auto.sh", "lint": "npx eslint . --ext .js,.jsx,.ts,.tsx", "fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", "pod-install-debug-static": "scripts/pod-install-debug-static.sh", diff --git a/samples/react-native/scripts/detect-ios-sim.sh b/samples/react-native/scripts/detect-ios-sim.sh new file mode 100755 index 0000000000..1ba6d3b98c --- /dev/null +++ b/samples/react-native/scripts/detect-ios-sim.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +if [ -z "$IOS_DEVICE" ]; then + # Get the first booted simulator device type and version + BOOTED_DEVICE=$(xcrun simctl list devices | grep "Booted" | head -n 1) + + if [ -z "$BOOTED_DEVICE" ]; then + echo "No booted iOS simulator found" + exit 1 + fi + + # Extract device type from booted device + export IOS_DEVICE=$(echo "$BOOTED_DEVICE" | cut -d "(" -f1 | xargs) + echo "Using booted iOS simulator: $IOS_DEVICE" +fi diff --git a/samples/react-native/scripts/test-ios-auto.sh b/samples/react-native/scripts/test-ios-auto.sh new file mode 100755 index 0000000000..5798457ec2 --- /dev/null +++ b/samples/react-native/scripts/test-ios-auto.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/.." + +"${thisFilePath}/detect-ios-sim.sh" + +detox test --configuration ci.sim --app-launch-args="--sentry-disable-native-start" diff --git a/samples/react-native/scripts/test-ios.sh b/samples/react-native/scripts/test-ios.sh index ee9b1bb83f..3e8d8c64a3 100755 --- a/samples/react-native/scripts/test-ios.sh +++ b/samples/react-native/scripts/test-ios.sh @@ -7,18 +7,6 @@ thisFilePath=$(dirname "$0") cd "${thisFilePath}/.." -if [ -z "$IOS_DEVICE" ]; then - # Get the first booted simulator device type and version - BOOTED_DEVICE=$(xcrun simctl list devices | grep "Booted" | head -n 1) - - if [ -z "$BOOTED_DEVICE" ]; then - echo "No booted iOS simulator found" - exit 1 - fi - - # Extract device type from booted device - export IOS_DEVICE=$(echo "$BOOTED_DEVICE" | cut -d "(" -f1 | xargs) - echo "Using booted iOS simulator: $IOS_DEVICE" -fi +"${thisFilePath}/detect-ios-sim.sh" detox test --configuration ci.sim From cbb85b2d556db8b81de01850caed0b60853ca76c Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:07:39 +0100 Subject: [PATCH 20/21] test(e2e): Add app start crash test for iOS (#4593) --- .github/workflows/sample-application.yml | 2 +- samples/react-native/.detoxrc.js | 17 ++- .../captureAppStartCrash.test.ios.manual.ts | 121 ++++++++++++++++++ .../react-native/e2e/jest.config.android.js | 17 +-- .../{jest.config.js => jest.config.base.js} | 0 .../react-native/e2e/jest.config.ios.auto.js | 11 ++ samples/react-native/e2e/jest.config.ios.js | 13 -- .../e2e/jest.config.ios.manual.js | 11 ++ .../sentryreactnativesample.xcscheme | 4 + .../sentryreactnativesample/AppDelegate.mm | 12 ++ samples/react-native/package.json | 2 +- samples/react-native/scripts/test-ios-auto.sh | 2 +- .../{test-ios.sh => test-ios-manual.sh} | 2 +- 13 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts rename samples/react-native/e2e/{jest.config.js => jest.config.base.js} (100%) create mode 100644 samples/react-native/e2e/jest.config.ios.auto.js delete mode 100644 samples/react-native/e2e/jest.config.ios.js create mode 100644 samples/react-native/e2e/jest.config.ios.manual.js rename samples/react-native/scripts/{test-ios.sh => test-ios-manual.sh} (78%) diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 3bfa290fe3..0c697f22e5 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -209,7 +209,7 @@ jobs: rn-architecture: 'new' ios-use-frameworks: 'no-frameworks' build-type: 'production' - test-command: 'yarn test-ios' + test-command: 'yarn test-ios-manual' - job-name: 'Test Android Release Manual Init' platform: android diff --git a/samples/react-native/.detoxrc.js b/samples/react-native/.detoxrc.js index 11c535ba65..a3501515a9 100644 --- a/samples/react-native/.detoxrc.js +++ b/samples/react-native/.detoxrc.js @@ -44,13 +44,26 @@ module.exports = { }, }, }, - 'ci.sim': { + 'ci.sim.auto': { device: 'ci.simulator', app: 'ci.ios', testRunner: { args: { $0: 'jest', - config: 'e2e/jest.config.ios.js', + config: 'e2e/jest.config.ios.auto.js', + }, + jest: { + setupTimeout: 120000, + }, + }, + }, + 'ci.sim.manual': { + device: 'ci.simulator', + app: 'ci.ios', + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.ios.manual.js', }, jest: { setupTimeout: 120000, diff --git a/samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts b/samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts new file mode 100644 index 0000000000..ea6750434e --- /dev/null +++ b/samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts @@ -0,0 +1,121 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEvent, +} from './utils/mockedSentryServer'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture app start crash', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + const launchConfig = { + launchArgs: { + 0: '--sentry-crash-on-start', + }, + }; + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + + device.launchApp(launchConfig); + device.launchApp(launchConfig); // This launch sends the crash event before the app crashes again + + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains sdk metadata', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + platform: 'cocoa', + sdk: { + features: [], + integrations: [ + 'SessionReplay', + 'WatchdogTerminationTracking', + 'Screenshot', + 'Crash', + 'ANRTracking', + 'ViewHierarchy', + 'AutoBreadcrumbTracking', + 'AutoSessionTracking', + 'NetworkTracking', + 'AppStartTracking', + 'FramesTracking', + ], + name: 'sentry.cocoa.react-native', + packages: [ + { + name: 'cocoapods:getsentry/sentry.cocoa.react-native', + version: expect.any(String), + }, + { + name: 'npm:@sentry/react-native', + version: expect.any(String), + }, + ], + version: expect.any(String), + }, + tags: { + 'event.environment': 'native', + 'event.origin': 'ios', + }, + }), + ]); + }); + + it('envelope contains the expected exception', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + mechanism: expect.objectContaining({ + handled: false, + meta: { + mach_exception: { + code: 0, + exception: 10, + name: 'EXC_CRASH', + subcode: 0, + }, + signal: { + code: 0, + name: 'SIGABRT', + number: 6, + }, + }, + type: 'nsexception', + }), + stacktrace: expect.objectContaining({ + frames: expect.any(Array), + }), + type: 'CrashOnStart', + value: 'This was intentional test crash before JS started.', + }), + ]), + }, + }), + ]); + }); +}); diff --git a/samples/react-native/e2e/jest.config.android.js b/samples/react-native/e2e/jest.config.android.js index 2d755851a0..a07210b212 100644 --- a/samples/react-native/e2e/jest.config.android.js +++ b/samples/react-native/e2e/jest.config.android.js @@ -1,16 +1,7 @@ +const baseConfig = require('./jest.config.base'); + /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { - preset: 'ts-jest', - rootDir: '..', - testMatch: [ - '/e2e/**/*.test.ts', - '/e2e/**/*.test.android.ts', - ], - testTimeout: 120000, - maxWorkers: 1, - globalSetup: 'detox/runners/jest/globalSetup', - globalTeardown: 'detox/runners/jest/globalTeardown', - reporters: ['detox/runners/jest/reporter'], - testEnvironment: 'detox/runners/jest/testEnvironment', - verbose: true, + ...baseConfig, + testMatch: [...baseConfig.testMatch, '/e2e/**/*.test.android.ts'], }; diff --git a/samples/react-native/e2e/jest.config.js b/samples/react-native/e2e/jest.config.base.js similarity index 100% rename from samples/react-native/e2e/jest.config.js rename to samples/react-native/e2e/jest.config.base.js diff --git a/samples/react-native/e2e/jest.config.ios.auto.js b/samples/react-native/e2e/jest.config.ios.auto.js new file mode 100644 index 0000000000..ebfadcaeb6 --- /dev/null +++ b/samples/react-native/e2e/jest.config.ios.auto.js @@ -0,0 +1,11 @@ +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + testMatch: [ + ...baseConfig.testMatch, + '/e2e/**/*.test.ios.ts', + '/e2e/**/*.test.ios.auto.ts', + ], +}; diff --git a/samples/react-native/e2e/jest.config.ios.js b/samples/react-native/e2e/jest.config.ios.js deleted file mode 100644 index 62034bc390..0000000000 --- a/samples/react-native/e2e/jest.config.ios.js +++ /dev/null @@ -1,13 +0,0 @@ -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - preset: 'ts-jest', - rootDir: '..', - testMatch: ['/e2e/**/*.test.ts', '/e2e/**/*.test.ios.ts'], - testTimeout: 120000, - maxWorkers: 1, - globalSetup: 'detox/runners/jest/globalSetup', - globalTeardown: 'detox/runners/jest/globalTeardown', - reporters: ['detox/runners/jest/reporter'], - testEnvironment: 'detox/runners/jest/testEnvironment', - verbose: true, -}; diff --git a/samples/react-native/e2e/jest.config.ios.manual.js b/samples/react-native/e2e/jest.config.ios.manual.js new file mode 100644 index 0000000000..fe271fca7d --- /dev/null +++ b/samples/react-native/e2e/jest.config.ios.manual.js @@ -0,0 +1,11 @@ +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + testMatch: [ + ...baseConfig.testMatch, + '/e2e/**/*.test.ios.ts', + '/e2e/**/*.test.ios.manual.ts', + ], +}; diff --git a/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme b/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme index 61b12d2c2c..1b6c7a3f15 100644 --- a/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme +++ b/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme @@ -65,6 +65,10 @@ argument = "--sentry-disable-native-start" isEnabled = "NO"> + + *arguments = [[NSProcessInfo processInfo] arguments]; + return [arguments containsObject:@"--sentry-crash-on-start"]; +} + @end diff --git a/samples/react-native/package.json b/samples/react-native/package.json index a5b36afd9d..8e80a5d598 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -15,7 +15,7 @@ "set-test-dsn-android": "scripts/set-dsn-aos.mjs", "set-test-dsn-ios": "scripts/set-dsn-ios.mjs", "test-android": "scripts/test-android.sh", - "test-ios": "scripts/test-ios.sh", + "test-ios-manual": "scripts/test-ios-manual.sh", "test-ios-auto": "scripts/test-ios-auto.sh", "lint": "npx eslint . --ext .js,.jsx,.ts,.tsx", "fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", diff --git a/samples/react-native/scripts/test-ios-auto.sh b/samples/react-native/scripts/test-ios-auto.sh index 5798457ec2..9192a9619e 100755 --- a/samples/react-native/scripts/test-ios-auto.sh +++ b/samples/react-native/scripts/test-ios-auto.sh @@ -9,4 +9,4 @@ cd "${thisFilePath}/.." "${thisFilePath}/detect-ios-sim.sh" -detox test --configuration ci.sim --app-launch-args="--sentry-disable-native-start" +detox test --configuration ci.sim.auto --app-launch-args="--sentry-disable-native-start" diff --git a/samples/react-native/scripts/test-ios.sh b/samples/react-native/scripts/test-ios-manual.sh similarity index 78% rename from samples/react-native/scripts/test-ios.sh rename to samples/react-native/scripts/test-ios-manual.sh index 3e8d8c64a3..b1c295edda 100755 --- a/samples/react-native/scripts/test-ios.sh +++ b/samples/react-native/scripts/test-ios-manual.sh @@ -9,4 +9,4 @@ cd "${thisFilePath}/.." "${thisFilePath}/detect-ios-sim.sh" -detox test --configuration ci.sim +detox test --configuration ci.sim.manual From b4ee16bebfd73b4edd90929a360e0062529fcc8f Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:13:17 +0100 Subject: [PATCH 21/21] test(e2e): Avoid race conditions when waiting for captured message (#4595) --- .../e2e/captureMessage.test.android.ts | 6 ++++-- .../e2e/captureMessage.test.ios.ts | 6 ++++-- .../e2e/envelopeHeader.test.android.ts | 6 ++++-- .../e2e/envelopeHeader.test.ios.ts | 6 ++++-- .../e2e/utils/mockedSentryServer.ts | 21 +++++++++++++++++++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/samples/react-native/e2e/captureMessage.test.android.ts b/samples/react-native/e2e/captureMessage.test.android.ts index d08473ac2b..c48d9553a5 100644 --- a/samples/react-native/e2e/captureMessage.test.android.ts +++ b/samples/react-native/e2e/captureMessage.test.android.ts @@ -3,7 +3,7 @@ import { Envelope, EventItem } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, - containingEvent, + containingEventWithAndroidMessage, } from './utils/mockedSentryServer'; import { tap } from './utils/tap'; import { getItemOfTypeFrom } from './utils/event'; @@ -17,7 +17,9 @@ describe('Capture message', () => { beforeAll(async () => { await device.launchApp(); - const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithAndroidMessage('Captured message'), + ); await tap('Capture message'); envelope = await envelopePromise; }); diff --git a/samples/react-native/e2e/captureMessage.test.ios.ts b/samples/react-native/e2e/captureMessage.test.ios.ts index 9e3a804881..40cfcf20c1 100644 --- a/samples/react-native/e2e/captureMessage.test.ios.ts +++ b/samples/react-native/e2e/captureMessage.test.ios.ts @@ -3,7 +3,7 @@ import { Envelope, EventItem } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, - containingEvent, + containingEventWithMessage, } from './utils/mockedSentryServer'; import { tap } from './utils/tap'; import { getItemOfTypeFrom } from './utils/event'; @@ -17,7 +17,9 @@ describe('Capture message', () => { beforeAll(async () => { await device.launchApp(); - const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithMessage('Captured message'), + ); await tap('Capture message'); envelope = await envelopePromise; }); diff --git a/samples/react-native/e2e/envelopeHeader.test.android.ts b/samples/react-native/e2e/envelopeHeader.test.android.ts index 4be175d6c9..dc4a75676d 100644 --- a/samples/react-native/e2e/envelopeHeader.test.android.ts +++ b/samples/react-native/e2e/envelopeHeader.test.android.ts @@ -3,7 +3,7 @@ import { Envelope } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, - containingEvent, + containingEventWithAndroidMessage, } from './utils/mockedSentryServer'; import { HEADER } from './utils/consts'; import { tap } from './utils/tap'; @@ -17,7 +17,9 @@ describe('Capture message', () => { beforeAll(async () => { await device.launchApp(); - const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithAndroidMessage('Captured message'), + ); await tap('Capture message'); envelope = await envelopePromise; diff --git a/samples/react-native/e2e/envelopeHeader.test.ios.ts b/samples/react-native/e2e/envelopeHeader.test.ios.ts index 04ce534226..5798b07d5d 100644 --- a/samples/react-native/e2e/envelopeHeader.test.ios.ts +++ b/samples/react-native/e2e/envelopeHeader.test.ios.ts @@ -3,7 +3,7 @@ import { Envelope } from '@sentry/core'; import { device } from 'detox'; import { createSentryServer, - containingEvent, + containingEventWithMessage, } from './utils/mockedSentryServer'; import { HEADER } from './utils/consts'; import { tap } from './utils/tap'; @@ -17,7 +17,9 @@ describe('Capture message', () => { beforeAll(async () => { await device.launchApp(); - const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithMessage('Captured message'), + ); await tap('Capture message'); envelope = await envelopePromise; diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts index 7f19a0d24b..9c738ce6a5 100644 --- a/samples/react-native/e2e/utils/mockedSentryServer.ts +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -94,6 +94,27 @@ export function containingEvent(envelope: Envelope) { return envelope[1].some(item => itemHeaderIsType(item[0], 'event')); } +export function containingEventWithAndroidMessage(message: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'event') && + itemBodyIsEvent(item[1]) && + item[1].message && + (item[1].message as unknown as { message: string }).message === message, + ); +} + +export function containingEventWithMessage(message: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'event') && + itemBodyIsEvent(item[1]) && + item[1].message === message, + ); +} + export function containingTransactionWithName(name: string) { return (envelope: Envelope) => envelope[1].some(