diff --git a/CHANGELOG.md b/CHANGELOG.md index f59bae5098..a88f4a55bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ }); ``` +### Fix + +- Sync `user.geo` from `SetUser` to the native layer ([#5302](https://github.com/getsentry/sentry-react-native/pull/5302)) + ### Dependencies - Bump Bundler Plugins from v4.4.0 to v4.6.0 ([#5283](https://github.com/getsentry/sentry-react-native/pull/5283), [#5314](https://github.com/getsentry/sentry-react-native/pull/5314)) 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 63b10812fe..3f1cc53b75 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 @@ -272,4 +272,90 @@ class RNSentryModuleImplTest { val regex = Regex(options.ignoredErrors!![0].filterString) assertTrue(regex.matches("Error*WithStar")) } + + @Test + fun `setUser with geo data creates user with correct geo properties`() { + val userKeys = + JavaOnlyMap.of( + "id", + "123", + "email", + "test@example.com", + "username", + "testuser", + "geo", + JavaOnlyMap.of( + "city", + "San Francisco", + "country_code", + "US", + "region", + "California", + ), + ) + val userDataKeys = JavaOnlyMap.of("customField", "customValue") + + module.setUser(userKeys, userDataKeys) + } + + @Test + fun `setUser with partial geo data creates user with available geo properties`() { + val userKeys = + JavaOnlyMap.of( + "id", + "123", + "geo", + JavaOnlyMap.of( + "city", + "New York", + "country_code", + "US", + ), + ) + val userDataKeys = JavaOnlyMap.of() + + module.setUser(userKeys, userDataKeys) + } + + @Test + fun `setUser with empty geo data handles empty geo object`() { + val userKeys = + JavaOnlyMap.of( + "id", + "123", + "geo", + JavaOnlyMap.of(), + ) + val userDataKeys = JavaOnlyMap.of() + + module.setUser(userKeys, userDataKeys) + } + + @Test + fun `setUser with null geo data handles null geo gracefully`() { + val userKeys = + JavaOnlyMap.of( + "id", + "123", + "geo", + null, + ) + val userDataKeys = JavaOnlyMap.of() + + module.setUser(userKeys, userDataKeys) + } + + @Test + fun `setUser with invalid geo data handles non-map geo gracefully`() { + val userKeys = + JavaOnlyMap.of( + "id", + "123", + "geo", + "invalid_geo_data", + ) + val userDataKeys = JavaOnlyMap.of() + + module.setUser(userKeys, userDataKeys) + } } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.h index 43a25477fc..0de744facc 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.h @@ -2,8 +2,16 @@ #import @class SentryOptions; +@class SentryUser; @interface SentrySDKInternal (PrivateTests) + (nullable SentryOptions *)options; @end + +@interface RNSentry (PrivateTests) + ++ (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys + otherUserKeys:(NSDictionary *)userDataKeys; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m index e7067f894d..9e600cdbda 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.m @@ -857,4 +857,90 @@ - (void)testCreateOptionsWithDictionaryEnableSessionReplayInUnreliableEnvironmen @"enableSessionReplayInUnreliableEnvironment should be disabled"); } +- (void)testCreateUserWithGeoDataCreatesSentryGeoObject +{ + NSDictionary *userKeys = @{ + @"id" : @"123", + @"email" : @"test@example.com", + @"username" : @"testuser", + @"geo" : + @ { @"city" : @"San Francisco", @"country_code" : @"US", @"region" : @"California" } + }; + + NSDictionary *userDataKeys = @{ @"customField" : @"customValue" }; + + SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; + + XCTAssertNotNil(user, @"User should not be nil"); + XCTAssertEqual(user.userId, @"123", @"User ID should match"); + XCTAssertEqual(user.email, @"test@example.com", @"Email should match"); + XCTAssertEqual(user.username, @"testuser", @"Username should match"); + + // Test that geo data is properly converted to SentryGeo object + XCTAssertNotNil(user.geo, @"Geo should not be nil"); + XCTAssertTrue([user.geo isKindOfClass:[SentryGeo class]], @"Geo should be SentryGeo object"); + XCTAssertEqual(user.geo.city, @"San Francisco", @"City should match"); + XCTAssertEqual(user.geo.countryCode, @"US", @"Country code should match"); + XCTAssertEqual(user.geo.region, @"California", @"Region should match"); + + // Test that custom data is preserved + XCTAssertNotNil(user.data, @"User data should not be nil"); + XCTAssertEqual(user.data[@"customField"], @"customValue", @"Custom field should be preserved"); +} + +- (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject +{ + NSDictionary *userKeys = + @{ @"id" : @"456", @"geo" : @ { @"city" : @"New York", @"country_code" : @"US" } }; + + NSDictionary *userDataKeys = @{}; + + SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; + + XCTAssertNotNil(user, @"User should not be nil"); + XCTAssertEqual(user.userId, @"456", @"User ID should match"); + + // Test that partial geo data is properly converted to SentryGeo object + XCTAssertNotNil(user.geo, @"Geo should not be nil"); + XCTAssertTrue([user.geo isKindOfClass:[SentryGeo class]], @"Geo should be SentryGeo object"); + XCTAssertEqual(user.geo.city, @"New York", @"City should match"); + XCTAssertEqual(user.geo.countryCode, @"US", @"Country code should match"); + XCTAssertNil(user.geo.region, @"Region should be nil when not provided"); +} + +- (void)testCreateUserWithEmptyGeoDataCreatesSentryGeoObject +{ + NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ {} }; + + NSDictionary *userDataKeys = @{}; + + SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; + + XCTAssertNotNil(user, @"User should not be nil"); + XCTAssertEqual(user.userId, @"789", @"User ID should match"); + + // Test that empty geo data is properly converted to SentryGeo object + XCTAssertNotNil(user.geo, @"Geo should not be nil"); + XCTAssertTrue([user.geo isKindOfClass:[SentryGeo class]], @"Geo should be SentryGeo object"); + XCTAssertNil(user.geo.city, @"City should be nil when not provided"); + XCTAssertNil(user.geo.countryCode, @"Country code should be nil when not provided"); + XCTAssertNil(user.geo.region, @"Region should be nil when not provided"); +} + +- (void)testCreateUserWithoutGeoDataDoesNotCreateGeoObject +{ + NSDictionary *userKeys = @{ @"id" : @"999", @"email" : @"test@example.com" }; + + NSDictionary *userDataKeys = @{}; + + SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys]; + + XCTAssertNotNil(user, @"User should not be nil"); + XCTAssertEqual(user.userId, @"999", @"User ID should match"); + XCTAssertEqual(user.email, @"test@example.com", @"Email should match"); + + // Test that no geo object is created when geo data is not provided + XCTAssertNil(user.geo, @"Geo should be nil when not provided"); +} + @end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m index 987dc6d2e1..542904cbb5 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryUserTests.m @@ -1,5 +1,6 @@ #import "RNSentry+Test.h" #import "RNSentryTests.h" +#import #import @interface RNSentryUserTests : XCTestCase @@ -102,4 +103,73 @@ - (void)testNullValuesUser XCTAssertTrue([actual isEqualToUser:expected]); } +- (void)testUserWithGeo +{ + SentryUser *expected = [SentryUser alloc]; + [expected setUserId:@"123"]; + [expected setEmail:@"test@example.com"]; + [expected setUsername:@"testuser"]; + + SentryGeo *expectedGeo = [SentryGeo alloc]; + [expectedGeo setCity:@"San Francisco"]; + [expectedGeo setCountryCode:@"US"]; + [expectedGeo setRegion:@"California"]; + [expected setGeo:expectedGeo]; + + SentryUser *actual = [RNSentry userFrom:@{ + @"id" : @"123", + @"email" : @"test@example.com", + @"username" : @"testuser", + @"geo" : + @ { @"city" : @"San Francisco", @"country_code" : @"US", @"region" : @"California" } + } + otherUserKeys:nil]; + + XCTAssertTrue([actual isEqualToUser:expected]); +} + +- (void)testUserWithPartialGeo +{ + SentryUser *expected = [[SentryUser alloc] init]; + [expected setUserId:@"123"]; + + SentryGeo *expectedGeo = [SentryGeo alloc]; + [expectedGeo setCity:@"New York"]; + [expectedGeo setCountryCode:@"US"]; + [expected setGeo:expectedGeo]; + + SentryUser *actual = [RNSentry userFrom:@{ + @"id" : @"123", + @"geo" : @ { @"city" : @"New York", @"country_code" : @"US" } + } + otherUserKeys:nil]; + + XCTAssertTrue([actual isEqualToUser:expected]); +} + +- (void)testUserWithEmptyGeo +{ + SentryUser *expected = [[SentryUser alloc] init]; + [expected setUserId:@"123"]; + + // Empty geo dictionary creates an empty SentryGeo object + SentryGeo *expectedGeo = [SentryGeo alloc]; + [expected setGeo:expectedGeo]; + + SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ {} } otherUserKeys:nil]; + + XCTAssertTrue([actual isEqualToUser:expected]); +} + +- (void)testUserWithInvalidGeo +{ + SentryUser *expected = [[SentryUser alloc] init]; + [expected setUserId:@"123"]; + + SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @"invalid_geo_data" } + otherUserKeys:nil]; + + XCTAssertTrue([actual isEqualToUser:expected]); +} + @end diff --git a/packages/core/android/libs/replay-stubs.jar b/packages/core/android/libs/replay-stubs.jar index b3f1f1b626..ea82bc337b 100644 Binary files a/packages/core/android/libs/replay-stubs.jar and b/packages/core/android/libs/replay-stubs.jar differ 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 9deb3e0f1f..c3f1b8a8c0 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 @@ -63,6 +63,7 @@ import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.protocol.Geo; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryPackage; @@ -739,6 +740,23 @@ public void setUser(final ReadableMap userKeys, final ReadableMap userDataKeys) if (userKeys.hasKey("ip_address")) { userInstance.setIpAddress(userKeys.getString("ip_address")); } + + if (userKeys.hasKey("geo")) { + ReadableMap geoMap = userKeys.getMap("geo"); + if (geoMap != null) { + Geo geoData = new Geo(); + if (geoMap.hasKey("city")) { + geoData.setCity(geoMap.getString("city")); + } + if (geoMap.hasKey("country_code")) { + geoData.setCountryCode(geoMap.getString("country_code")); + } + if (geoMap.hasKey("region")) { + geoData.setRegion(geoMap.getString("region")); + } + userInstance.setGeo(geoData); + } + } } if (userDataKeys != null) { diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 747545e8bb..47c52332ee 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -25,6 +25,7 @@ #import #import #import +#import #import #import #import @@ -722,6 +723,29 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys [userInstance setUsername:username]; } + id geo = [userKeys valueForKey:@"geo"]; + if ([geo isKindOfClass:NSDictionary.class]) { + NSDictionary *geoDict = (NSDictionary *)geo; + SentryGeo *sentryGeo = [SentryGeo alloc]; + + id city = [geoDict valueForKey:@"city"]; + if ([city isKindOfClass:NSString.class]) { + [sentryGeo setCity:city]; + } + + id countryCode = [geoDict valueForKey:@"country_code"]; + if ([countryCode isKindOfClass:NSString.class]) { + [sentryGeo setCountryCode:countryCode]; + } + + id region = [geoDict valueForKey:@"region"]; + if ([region isKindOfClass:NSString.class]) { + [sentryGeo setRegion:region]; + } + + [userInstance setGeo:sentryGeo]; + } + if ([userDataKeys isKindOfClass:NSDictionary.class]) { [userInstance setData:userDataKeys]; } diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 4bbce147b7..716aac0a1c 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -394,13 +394,13 @@ export const NATIVE: SentryNativeWrapper = { let userKeys = null; let userDataKeys = null; if (user) { - const { id, ip_address, email, username, ...otherKeys } = user; - // TODO: Update native impl to use geo - const requiredUser: Omit = { + const { id, ip_address, email, username, geo, ...otherKeys } = user; + const requiredUser: RequiredKeysUser = { id, ip_address, email, username, + geo, }; userKeys = this._serializeObject(requiredUser); userDataKeys = this._serializeObject(otherKeys); diff --git a/packages/core/test/scopeSync.test.ts b/packages/core/test/scopeSync.test.ts index 29c6efae29..989490de6e 100644 --- a/packages/core/test/scopeSync.test.ts +++ b/packages/core/test/scopeSync.test.ts @@ -141,6 +141,22 @@ describe('ScopeSync', () => { expect(setUserScopeSpy).toHaveBeenCalledExactlyOnceWith({ id: '123' }); }); + it('setUser with geo data', () => { + expect(SentryCore.getIsolationScope().setUser).not.toBe(setUserScopeSpy); + const user = { + id: '123', + email: 'test@example.com', + geo: { + city: 'San Francisco', + country_code: 'US', + region: 'California', + }, + }; + SentryCore.setUser(user); + expect(NATIVE.setUser).toHaveBeenCalledExactlyOnceWith(user); + expect(setUserScopeSpy).toHaveBeenCalledExactlyOnceWith(user); + }); + it('setTag', () => { jest.spyOn(NATIVE, 'primitiveProcessor').mockImplementation((value: SentryCore.Primitive) => value as string); expect(SentryCore.getIsolationScope().setTag).not.toBe(setTagScopeSpy); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index 8fc14c89ea..cf7f7cc818 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -618,6 +618,87 @@ describe('Tests Native Wrapper', () => { {}, ); }); + + test('serializes user with geo data', async () => { + NATIVE.setUser({ + id: '123', + email: 'test@example.com', + username: 'testuser', + geo: { + city: 'San Francisco', + country_code: 'US', + region: 'California', + }, + customField: 'customValue', + }); + + expect(RNSentry.setUser).toBeCalledWith( + { + id: '123', + email: 'test@example.com', + username: 'testuser', + geo: JSON.stringify({ + city: 'San Francisco', + country_code: 'US', + region: 'California', + }), + }, + { + customField: 'customValue', + }, + ); + }); + + test('serializes user with partial geo data', async () => { + NATIVE.setUser({ + id: '123', + geo: { + city: 'New York', + country_code: 'US', + }, + }); + + expect(RNSentry.setUser).toBeCalledWith( + { + id: '123', + geo: JSON.stringify({ + city: 'New York', + country_code: 'US', + }), + }, + {}, + ); + }); + + test('serializes user with empty geo data', async () => { + NATIVE.setUser({ + id: '123', + geo: {}, + }); + + expect(RNSentry.setUser).toBeCalledWith( + { + id: '123', + geo: '{}', + }, + {}, + ); + }); + + test('serializes user with undefined geo', async () => { + NATIVE.setUser({ + id: '123', + geo: undefined, + }); + + expect(RNSentry.setUser).toBeCalledWith( + { + id: '123', + geo: undefined, + }, + {}, + ); + }); }); describe('_processLevel', () => {