diff --git a/NATIVE_SDK_VERSIONS.md b/NATIVE_SDK_VERSIONS.md index b2f2be2e4..be3420d0b 100644 --- a/NATIVE_SDK_VERSIONS.md +++ b/NATIVE_SDK_VERSIONS.md @@ -1,5 +1,6 @@ | React Native | iOS Bridge / iOS SDK | Android Bridge / Android SDK | |-------------|---------------------|-----------------------------| +| Unreleased | 3.16.1 | 3.16.0 | | 3.11.0 | 3.16.1 | 3.15.0 | | 3.10.0 | 3.16.0 | 3.13.1 | | 3.9.0 | 3.16.0 | 3.13.1 | @@ -84,4 +85,4 @@ | 1.6.0 | 1.19.0 | 1.19.0 | | 1.5.2 | 1.19.0 | 1.18.1 | | 1.5.1 | 1.16.0 | 1.18.1 | -| 1.5.0 | 1.16.0 | 1.18.1 | \ No newline at end of file +| 1.5.0 | 1.16.0 | 1.18.1 | diff --git a/benchmarks/android/app/build.gradle b/benchmarks/android/app/build.gradle index 1a4f94b95..98708153a 100644 --- a/benchmarks/android/app/build.gradle +++ b/benchmarks/android/app/build.gradle @@ -129,7 +129,7 @@ dependencies { // Benchmark tools from dd-sdk-android are used for vitals recording // Remember to bump this alongside the main dd-sdk-android dependencies - implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.15.0") + implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.16.0") // Required for compile-time access to Datadog.getInstance() required by BenchmarkVitalsModuleImpl - implementation("com.datadoghq:dd-sdk-android-core:3.15.0") + implementation("com.datadoghq:dd-sdk-android-core:3.16.0") } diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index c95ee2b16..f6f7ac3ff 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -213,7 +213,7 @@ dependencies { // This breaks builds if the React Native target is below 0.76.0. as it relies on Gradle 8.5.0. // To avoid this, we enforce 1.0.0-beta01 on RN < 0.76.0 if (reactNativeMinorVersion < 76) { - implementation("com.datadoghq:dd-sdk-android-rum:3.15.0") { + implementation("com.datadoghq:dd-sdk-android-rum:3.16.0") { exclude group: "androidx.metrics", module: "metrics-performance" } implementation "androidx.metrics:metrics-performance:1.0.0-beta01" @@ -233,15 +233,15 @@ dependencies { } } } else { - implementation "com.datadoghq:dd-sdk-android-rum:3.15.0" + implementation "com.datadoghq:dd-sdk-android-rum:3.16.0" } - implementation "com.datadoghq:dd-sdk-android-logs:3.15.0" - implementation "com.datadoghq:dd-sdk-android-trace:3.15.0" - implementation "com.datadoghq:dd-sdk-android-webview:3.15.0" - implementation "com.datadoghq:dd-sdk-android-ndk:3.15.0" - implementation "com.datadoghq:dd-sdk-android-flags:3.15.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.15.0" - implementation "com.datadoghq:dd-sdk-android-rum-prelaunch:3.15.0" + implementation "com.datadoghq:dd-sdk-android-logs:3.16.0" + implementation "com.datadoghq:dd-sdk-android-trace:3.16.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.16.0" + implementation "com.datadoghq:dd-sdk-android-ndk:3.16.0" + implementation "com.datadoghq:dd-sdk-android-flags:3.16.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.16.0" + implementation "com.datadoghq:dd-sdk-android-rum-prelaunch:3.16.0" implementation "com.google.code.gson:gson:2.11.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2" diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt index 954e1eff5..7d8e05451 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -130,6 +130,87 @@ class DdFlagsImplementation( promise.resolve(null) } + /** + * Track a flag exposure without recording an evaluation event or updating RUM. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param rawFlag The flag assignment from JavaScript. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param promise The promise to resolve after the native call, without waiting for upload. + */ + @Suppress("LongParameterList") + fun trackExposure( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + _FlagsInternalProxy(getClient(clientName)).trackFlagExposure( + key, + convertMapToUnparsedFlag(rawFlag.toMap()), + buildEvaluationContext(targetingKey, attributes), + ) + promise.resolve(null) + } + + /** + * Track an evaluation event without recording an exposure or updating RUM. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param rawFlag The flag assignment from JavaScript, if available. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param errorCode The evaluation error code, or null for a successful evaluation. + * @param promise The promise to resolve after the native call, without waiting for upload. + */ + @Suppress("LongParameterList") + fun trackEvaluationEvent( + clientName: String, + key: String, + rawFlag: ReadableMap?, + targetingKey: String, + attributes: ReadableMap, + errorCode: String?, + promise: Promise, + ) { + _FlagsInternalProxy(getClient(clientName)).trackFlagEvaluationEvent( + key, + rawFlag?.let { convertMapToUnparsedFlag(it.toMap()) }, + buildEvaluationContext(targetingKey, attributes), + errorCode, + ) + promise.resolve(null) + } + + /** + * Associate a flag evaluation with RUM without recording an exposure or evaluation event. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param variant The evaluated flag variant. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param promise The promise to resolve after the native call, without waiting for upload. + */ + @Suppress("LongParameterList") + fun trackRumEvaluation( + clientName: String, + key: String, + variant: String, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + _FlagsInternalProxy(getClient(clientName)).trackFlagRumEvaluation( + key, + variant, + buildEvaluationContext(targetingKey, attributes), + ) + promise.resolve(null) + } + internal companion object { internal const val NAME = "DdFlags" } @@ -166,6 +247,9 @@ private fun buildFlagsConfiguration(configuration: Map): FlagsConf (configuration["customExposureEndpoint"] as? String)?.let { useCustomExposureEndpoint("$it/api/v2/exposures") } + (configuration["customEvaluationEndpoint"] as? String)?.let { + useCustomEvaluationEndpoint("$it/api/v2/flagevaluation") + } }.build() } diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt index ffb67bf1d..20ab701a2 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt @@ -63,4 +63,49 @@ class DdFlags( ) { implementation.trackEvaluation(clientName, key, rawFlag, targetingKey, attributes, promise) } + + @ReactMethod + override fun trackRumEvaluation( + clientName: String, + key: String, + variant: String, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + implementation.trackRumEvaluation(clientName, key, variant, targetingKey, attributes, promise) + } + + @ReactMethod + override fun trackExposure( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + implementation.trackExposure(clientName, key, rawFlag, targetingKey, attributes, promise) + } + + @ReactMethod + override fun trackEvaluationEvent( + clientName: String, + key: String, + rawFlag: ReadableMap?, + targetingKey: String, + attributes: ReadableMap, + errorCode: String?, + promise: Promise, + ) { + implementation.trackEvaluationEvent( + clientName, + key, + rawFlag, + targetingKey, + attributes, + errorCode, + promise, + ) + } } diff --git a/packages/core/android/src/oldarchCommon/kotlin/com/datadog/reactnative/DdFlags.kt b/packages/core/android/src/oldarchCommon/kotlin/com/datadog/reactnative/DdFlags.kt index e9e51fc5d..5c1672b21 100644 --- a/packages/core/android/src/oldarchCommon/kotlin/com/datadog/reactnative/DdFlags.kt +++ b/packages/core/android/src/oldarchCommon/kotlin/com/datadog/reactnative/DdFlags.kt @@ -60,4 +60,56 @@ class DdFlags(reactContext: ReactApplicationContext) : ReactContextBaseJavaModul ) { implementation.trackEvaluation(clientName, key, rawFlag, targetingKey, attributes, promise) } + + @ReactMethod + fun trackRumEvaluation( + clientName: String, + key: String, + variant: String, + targetingKey: String, + attributes: ReadableMap, + promise: Promise + ) { + implementation.trackRumEvaluation( + clientName, + key, + variant, + targetingKey, + attributes, + promise + ) + } + + @ReactMethod + fun trackExposure( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + promise: Promise + ) { + implementation.trackExposure(clientName, key, rawFlag, targetingKey, attributes, promise) + } + + @ReactMethod + fun trackEvaluationEvent( + clientName: String, + key: String, + rawFlag: ReadableMap?, + targetingKey: String, + attributes: ReadableMap, + errorCode: String?, + promise: Promise + ) { + implementation.trackEvaluationEvent( + clientName, + key, + rawFlag, + targetingKey, + attributes, + errorCode, + promise + ) + } } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt index 4bede8d2c..e3fede34c 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt @@ -9,7 +9,11 @@ package com.datadog.reactnative import com.datadog.android.Datadog import com.datadog.android.api.SdkCore import com.datadog.android.flags.Flags +import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.UnparsedFlag import com.datadog.tools.unit.toReadableMap import com.facebook.react.bridge.Promise import org.assertj.core.api.Assertions.assertThat @@ -20,9 +24,14 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.same import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever @Extensions( ExtendWith(MockitoExtension::class) @@ -68,7 +77,9 @@ internal class DdFlagsImplementationTest { testedImplementation.enable(configuration, mockPromise) // Then - flagsMock.verify { Flags.enable(any(), same(initializedCore)) } + val flagsConfig = argumentCaptor() + flagsMock.verify { Flags.enable(flagsConfig.capture(), same(initializedCore)) } + assertThat(flagsConfig.firstValue).extracting("customEvaluationEndpoint").isNull() verify(mockPromise).resolve(null) } finally { flagsMock.close() @@ -76,6 +87,52 @@ internal class DdFlagsImplementationTest { } } + @Test + fun `M forward separate endpoint overrides W enable()`() { + val core = mock() + val configuration = mapOf( + "enabled" to true, + "customFlagsEndpoint" to "https://flags.example.com", + "customExposureEndpoint" to "https://exposure.example.com", + "customEvaluationEndpoint" to "https://evaluation.example.com" + ).toReadableMap() + + Mockito.mockStatic(Flags::class.java).use { flagsMock -> + DdFlagsImplementation(core).enable(configuration, mockPromise) + + val flagsConfig = argumentCaptor() + flagsMock.verify { Flags.enable(flagsConfig.capture(), same(core)) } + assertThat(flagsConfig.firstValue).extracting("customFlagEndpoint") + .isEqualTo("https://flags.example.com/precompute-assignments") + assertThat(flagsConfig.firstValue).extracting("customExposureEndpoint") + .isEqualTo("https://exposure.example.com/api/v2/exposures") + assertThat(flagsConfig.firstValue).extracting("customEvaluationEndpoint") + .isEqualTo("https://evaluation.example.com/api/v2/flagevaluation") + verify(mockPromise).resolve(null) + } + } + + @Test + fun `M preserve other endpoints W enable() with only an evaluation override`() { + val core = mock() + val configuration = mapOf( + "enabled" to true, + "customEvaluationEndpoint" to "https://evaluation.example.com/proxy" + ).toReadableMap() + + Mockito.mockStatic(Flags::class.java).use { flagsMock -> + DdFlagsImplementation(core).enable(configuration, mockPromise) + + val flagsConfig = argumentCaptor() + flagsMock.verify { Flags.enable(flagsConfig.capture(), same(core)) } + assertThat(flagsConfig.firstValue).extracting("customEvaluationEndpoint") + .isEqualTo("https://evaluation.example.com/proxy/api/v2/flagevaluation") + assertThat(flagsConfig.firstValue).extracting("customFlagEndpoint").isNull() + assertThat(flagsConfig.firstValue).extracting("customExposureEndpoint").isNull() + verify(mockPromise).resolve(null) + } + } + @Test fun `M omit null attributes W buildEvaluationContext()`() { // Given @@ -93,4 +150,149 @@ internal class DdFlagsImplementationTest { assertThat(context.attributes).containsOnlyKeys("country", "age") assertThat(context.attributes["country"]).isEqualTo("US") } + + @Test + fun `M only track exposure W trackExposure()`() { + val client = mock() + Mockito.mockConstruction(FlagsClient.Builder::class.java) { builder, _ -> + whenever(builder.build()).thenReturn(client) + }.use { + Mockito.mockConstruction(_FlagsInternalProxy::class.java).use { proxies -> + DdFlagsImplementation(mock()).trackExposure( + "tracking", + "flag", + mapOf( + "allocationKey" to "allocation", + "variationKey" to "on", + "doLog" to true, + "serialId" to "9007199254740993" + ).toReadableMap(), + "user", + mapOf("country" to "US").toReadableMap(), + mockPromise + ) + val proxy = proxies.constructed().single() + val flag = argumentCaptor() + val context = argumentCaptor() + verify(proxy).trackFlagExposure(eq("flag"), flag.capture(), context.capture()) + assertThat(flag.firstValue.serialId).isEqualTo(9_007_199_254_740_993L) + assertThat(flag.firstValue.variationKey).isEqualTo("on") + assertThat(context.firstValue.targetingKey).isEqualTo("user") + assertThat(context.firstValue.attributes).containsEntry("country", "US") + verifyNoMoreInteractions(proxy) + verifyNoInteractions(client) + verify(mockPromise).resolve(null) + } + } + } + + @Test + fun `M only track evaluation W trackEvaluationEvent()`() { + val client = mock() + Mockito.mockConstruction(FlagsClient.Builder::class.java) { builder, _ -> + whenever(builder.build()).thenReturn(client) + }.use { + Mockito.mockConstruction(_FlagsInternalProxy::class.java).use { proxies -> + DdFlagsImplementation(mock()).trackEvaluationEvent( + "tracking", + "flag", + mapOf( + "allocationKey" to "allocation", + "variationKey" to "on", + "doLog" to true, + "serialId" to "9007199254740993" + ).toReadableMap(), + "user", + mapOf("country" to "US").toReadableMap(), + null, + mockPromise + ) + val proxy = proxies.constructed().single() + val flag = argumentCaptor() + val context = argumentCaptor() + verify(proxy).trackFlagEvaluationEvent( + eq("flag"), + flag.capture(), + context.capture(), + eq(null) + ) + assertThat(flag.firstValue.allocationKey).isEqualTo("allocation") + assertThat(context.firstValue.targetingKey).isEqualTo("user") + verifyNoMoreInteractions(proxy) + verifyNoInteractions(client) + verify(mockPromise).resolve(null) + } + } + } + + @Test + fun `M forward missing assignment and error W trackEvaluationEvent()`() { + val client = mock() + Mockito.mockConstruction(FlagsClient.Builder::class.java) { builder, _ -> + whenever(builder.build()).thenReturn(client) + }.use { + Mockito.mockConstruction(_FlagsInternalProxy::class.java).use { proxies -> + DdFlagsImplementation(mock()).trackEvaluationEvent( + "tracking", + "missing", + null, + "user", + emptyMap().toReadableMap(), + "FLAG_NOT_FOUND", + mockPromise + ) + val proxy = proxies.constructed().single() + verify(proxy).trackFlagEvaluationEvent( + eq("missing"), + eq(null), + any(), + eq("FLAG_NOT_FOUND") + ) + verifyNoMoreInteractions(proxy) + verifyNoInteractions(client) + verify(mockPromise).resolve(null) + } + } + } + + @Test + fun `M only track RUM W trackRumEvaluation()`() { + // Given + val client = mock() + Mockito.mockConstruction(FlagsClient.Builder::class.java) { builder, _ -> + whenever(builder.build()).thenReturn(client) + }.use { + Mockito.mockConstruction(_FlagsInternalProxy::class.java).use { proxies -> + // When + DdFlagsImplementation(mock()).trackRumEvaluation( + "tracking", + "flag", + "on", + "user", + mapOf("country" to "US").toReadableMap(), + mockPromise + ) + + // Then + val proxy = proxies.constructed().single() + val context = argumentCaptor() + verify(proxy).trackFlagRumEvaluation(eq("flag"), eq("on"), context.capture()) + assertThat(context.firstValue.targetingKey).isEqualTo("user") + assertThat(context.firstValue.attributes).containsEntry("country", "US") + verifyNoMoreInteractions(proxy) + verifyNoInteractions(client) + verify(mockPromise).resolve(null) + } + } + } + + private fun rawFlag() = mapOf( + "allocationKey" to "allocation", + "variationKey" to "on", + "variationType" to "boolean", + "variationValue" to "true", + "reason" to "TARGETING_MATCH", + "doLog" to true, + "serialId" to "9007199254740993" + ).toReadableMap() } diff --git a/packages/core/ios/Sources/DdFlags.mm b/packages/core/ios/Sources/DdFlags.mm index 4cbe7a461..20e316b5b 100644 --- a/packages/core/ios/Sources/DdFlags.mm +++ b/packages/core/ios/Sources/DdFlags.mm @@ -44,6 +44,40 @@ @implementation DdFlags [self trackEvaluation:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(trackExposure:(NSString *)clientName + withKey:(NSString *)key + withRawFlag:(NSDictionary *)rawFlag + targetingKey:(NSString *)targetingKey + attributes:(NSDictionary *)attributes + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self trackExposure:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(trackEvaluationEvent:(NSString *)clientName + withKey:(NSString *)key + withRawFlag:(nullable NSDictionary *)rawFlag + targetingKey:(NSString *)targetingKey + attributes:(NSDictionary *)attributes + errorCode:(nullable NSString *)errorCode + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self trackEvaluationEvent:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes errorCode:errorCode resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(trackRumEvaluation:(NSString *)clientName + withKey:(NSString *)key + variant:(NSString *)variant + targetingKey:(NSString *)targetingKey + attributes:(NSDictionary *)attributes + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self trackRumEvaluation:clientName key:key variant:variant targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} + // Thanks to this guard, we won't compile this code when we build for the new architecture. #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule: @@ -80,4 +114,16 @@ - (void)setEvaluationContext:(NSString *)clientName targetingKey:(NSString *)tar - (void)trackEvaluation:(NSString *)clientName key:(NSString *)key rawFlag:(NSDictionary *)rawFlag targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.ddFlagsImplementation trackEvaluation:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; } + +- (void)trackExposure:(NSString *)clientName key:(NSString *)key rawFlag:(NSDictionary *)rawFlag targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation trackExposure:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} + +- (void)trackEvaluationEvent:(NSString *)clientName key:(NSString *)key rawFlag:(NSDictionary * _Nullable)rawFlag targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes errorCode:(NSString * _Nullable)errorCode resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation trackEvaluationEvent:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes errorCode:errorCode resolve:resolve reject:reject]; +} + +- (void)trackRumEvaluation:(NSString *)clientName key:(NSString *)key variant:(NSString *)variant targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation trackRumEvaluation:clientName key:key variant:variant targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} @end diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 34020f410..191555404 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -118,6 +118,26 @@ public class DdFlagsImplementation: NSObject { resolve(nil) } + @objc + public func trackExposure(_ clientName: String, key: String, rawFlag: NSDictionary, targetingKey: String, attributes: NSDictionary, resolve: @escaping RCTPromiseResolve, reject: @escaping RCTPromiseReject) { + rejectUnsupportedTracking(reject) + } + + @objc + public func trackEvaluationEvent(_ clientName: String, key: String, rawFlag: NSDictionary?, targetingKey: String, attributes: NSDictionary, errorCode: String?, resolve: @escaping RCTPromiseResolve, reject: @escaping RCTPromiseReject) { + rejectUnsupportedTracking(reject) + } + + @objc + public func trackRumEvaluation(_ clientName: String, key: String, variant: String, targetingKey: String, attributes: NSDictionary, resolve: @escaping RCTPromiseResolve, reject: @escaping RCTPromiseReject) { + rejectUnsupportedTracking(reject) + } + + // The shared TurboModule spec requires these methods, but iOS support is deferred. + private func rejectUnsupportedTracking(_ reject: RCTPromiseReject) { + reject("UNSUPPORTED_PLATFORM", "Independent flag tracking hooks are currently supported only on Android.", nil) + } + /// Construct an `FlagsEvaluationContext` from a targeting key and a dictionary of attributes. private func buildEvaluationContext(targetingKey: String, attributes: NSDictionary) -> FlagsEvaluationContext { let dict = attributes as? [String: Any] ?? [:] diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index 3fca72ddc..84e47e124 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -174,6 +174,33 @@ class DdFlagsTests: XCTestCase { waitForExpectations(timeout: 1.0, handler: nil) } + func testIndependentTrackingRejectsUnsupportedPlatformWithoutUsingClient() { + var clientRequested = false + implementation.clientProviders["tracking"] = { + clientRequested = true + return MockFlagsClient() + } + let flag: NSDictionary = [ + "allocationKey": "allocation", "variationKey": "on", "reason": "STATIC", + "doLog": true, "value": true + ] + var rejectionCount = 0 + let reject: RCTPromiseReject = { code, message, error in + XCTAssertEqual(code, "UNSUPPORTED_PLATFORM") + XCTAssertEqual(message, "Independent flag tracking hooks are currently supported only on Android.") + XCTAssertNil(error) + rejectionCount += 1 + } + let resolve: RCTPromiseResolve = { _ in XCTFail("Unsupported tracking must not report success") } + + implementation.trackExposure("tracking", key: "flag", rawFlag: flag, targetingKey: "user", attributes: [:], resolve: resolve, reject: reject) + implementation.trackEvaluationEvent("tracking", key: "flag", rawFlag: flag, targetingKey: "user", attributes: [:], errorCode: nil, resolve: resolve, reject: reject) + implementation.trackRumEvaluation("tracking", key: "flag", variant: "on", targetingKey: "user", attributes: [:], resolve: resolve, reject: reject) + + XCTAssertEqual(rejectionCount, 3) + XCTAssertFalse(clientRequested) + } + // MARK: - AnyValue Tests func testAnyValueWrapUnwrapNull() { diff --git a/packages/core/src/flags/__tests__/DdFlags.test.ts b/packages/core/src/flags/__tests__/DdFlags.test.ts index 01e65d431..0cd148684 100644 --- a/packages/core/src/flags/__tests__/DdFlags.test.ts +++ b/packages/core/src/flags/__tests__/DdFlags.test.ts @@ -40,6 +40,7 @@ describe('DdFlags', () => { it('should call the native enable method with the correct configuration', async () => { await DdFlags.enable({ customExposureEndpoint: 'https://example.com', + customEvaluationEndpoint: 'https://evaluations.example.com', customFlagsEndpoint: 'https://example.com', trackExposures: false, rumIntegrationEnabled: false @@ -48,12 +49,24 @@ describe('DdFlags', () => { expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ enabled: true, customExposureEndpoint: 'https://example.com', + customEvaluationEndpoint: 'https://evaluations.example.com', customFlagsEndpoint: 'https://example.com', trackExposures: false, rumIntegrationEnabled: false }); }); + it('should forward an evaluation endpoint without other endpoint overrides', async () => { + await DdFlags.enable({ + customEvaluationEndpoint: 'https://evaluations.example.com/proxy' + }); + + expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ + enabled: true, + customEvaluationEndpoint: 'https://evaluations.example.com/proxy' + }); + }); + it('should print an error when trying to retrieve a client before DdFlags.enable() has been called', async () => { DdFlags.getClient(); diff --git a/packages/core/src/flags/tracking.ts b/packages/core/src/flags/tracking.ts new file mode 100644 index 000000000..dc2eaaebf --- /dev/null +++ b/packages/core/src/flags/tracking.ts @@ -0,0 +1,98 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../InternalLog'; +import { SdkVerbosity } from '../config/types/SdkVerbosity'; +import NativeDdFlags from '../specs/NativeDdFlags'; +import type { Spec } from '../specs/NativeDdFlags'; + +import { processEvaluationContext } from './internal'; +import type { FlagCacheEntry } from './internal'; +import type { EvaluationContext } from './types'; + +type FlagTrackingAssignment = Pick< + FlagCacheEntry, + 'allocationKey' | 'variationKey' | 'doLog' | 'serialId' +>; + +/** @internal Used by the matching-version OpenFeature tracking hooks. */ +export function __ddTrackFlagExposure( + clientName: string, + key: string, + flag: FlagTrackingAssignment, + context: EvaluationContext +): void { + const captured = processEvaluationContext(context); + submit(native => + native.trackExposure( + clientName, + key, + flag, + captured.targetingKey, + captured.attributes ?? {} + ) + ); +} + +/** @internal Used by the matching-version OpenFeature tracking hooks. */ +export function __ddTrackFlagEvaluationEvent( + clientName: string, + key: string, + flag: FlagTrackingAssignment | null, + context: EvaluationContext, + errorCode: string | null +): void { + const captured = processEvaluationContext(context); + submit(native => + native.trackEvaluationEvent( + clientName, + key, + flag, + captured.targetingKey, + captured.attributes ?? {}, + errorCode + ) + ); +} + +/** @internal Used by the matching-version OpenFeature tracking hooks. */ +export function __ddTrackFlagRumEvaluation( + clientName: string, + key: string, + variant: string, + context: EvaluationContext +): void { + const captured = processEvaluationContext(context); + submit(native => + native.trackRumEvaluation( + clientName, + key, + variant, + captured.targetingKey, + captured.attributes ?? {} + ) + ); +} + +function submit(operation: (native: Spec) => Promise): void { + // Submit immediately. Evaluation must not wait for native acknowledgment or upload. + try { + if (!NativeDdFlags) { + logTrackingFailure(); + return; + } + operation(NativeDdFlags).catch(logTrackingFailure); + } catch { + logTrackingFailure(); + } +} + +function logTrackingFailure(): void { + InternalLog.log( + 'Unable to submit flag tracking. Ensure the native SDK supports the tracking hooks and Flags is enabled.', + SdkVerbosity.WARN + ); +} diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 6e5bf0c25..a0065bf93 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -88,6 +88,18 @@ export interface FlagsConfiguration { * @default undefined */ customExposureEndpoint?: string; + /** + * Android-only custom server URL for sending aggregated flag evaluation data. Ignored on iOS. + * + * The provided value should only include the base URL, and the endpoint will be appended automatically. + * For example, 'https://flags.example.com' becomes 'https://flags.example.com/api/v2/flagevaluation'. + * + * If not set, the SDK uses the default Datadog flag evaluation endpoint for the configured site. + * This does not change the flag assignments, exposure, or RUM endpoints. + * + * @default undefined + */ + customEvaluationEndpoint?: string; /** * Enables exposure logging via the dedicated exposures intake endpoint. * diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index fc704bc81..13bcdcf14 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -71,6 +71,12 @@ import { DefaultTimeProvider } from './utils/time-provider/DefaultTimeProvider'; import type { Timestamp } from './utils/time-provider/TimeProvider'; import { TimeProvider } from './utils/time-provider/TimeProvider'; +export { + __ddTrackFlagExposure, + __ddTrackFlagEvaluationEvent, + __ddTrackFlagRumEvaluation +} from './flags/tracking'; + export { DatadogProvider, DatadogProviderConfiguration, diff --git a/packages/core/src/specs/NativeDdFlags.ts b/packages/core/src/specs/NativeDdFlags.ts index 5c8afd010..b672f51ec 100644 --- a/packages/core/src/specs/NativeDdFlags.ts +++ b/packages/core/src/specs/NativeDdFlags.ts @@ -29,6 +29,30 @@ export interface Spec extends TurboModule { targetingKey: string, attributes: Object ) => Promise; + readonly trackExposure: ( + clientName: string, + key: string, + rawFlag: Object, + targetingKey: string, + attributes: Object + ) => Promise; + + readonly trackEvaluationEvent: ( + clientName: string, + key: string, + rawFlag: Object | null, + targetingKey: string, + attributes: Object, + errorCode: string | null + ) => Promise; + + readonly trackRumEvaluation: ( + clientName: string, + key: string, + variant: string, + targetingKey: string, + attributes: Object + ) => Promise; } // eslint-disable-next-line import/no-default-export diff --git a/packages/internal-testing-tools/android/build.gradle b/packages/internal-testing-tools/android/build.gradle index fe8296905..399da2c33 100644 --- a/packages/internal-testing-tools/android/build.gradle +++ b/packages/internal-testing-tools/android/build.gradle @@ -188,7 +188,7 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native') - implementation "com.datadoghq:dd-sdk-android-core:3.15.0" + implementation "com.datadoghq:dd-sdk-android-core:3.16.0" implementation "com.google.code.gson:gson:2.11.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index e3e693c26..6f39fc831 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -338,6 +338,93 @@ precomputed-only `configurationFromString` helper. `Error`. Loading a usable configuration after an error emits `Ready`, followed by `ConfigurationChanged`. +### Optional tracking with DatadogCoreProvider (Android only) + +Register exposure, evaluation, and RUM hooks independently with the evaluation-only +`DatadogCoreProvider`. Do not add these hooks to `DatadogOpenFeatureProvider` or +`DatadogOfflineOpenFeatureProvider`, which already track automatically. + +Independent tracking hooks are currently supported only on Android, using native SDK +3.16.0. In shared application code, guard this setup with `Platform.OS === 'android'`. +On iOS, `initialize()` warns once per hook and leaves it inactive; flag evaluation and +the existing providers' automatic tracking remain unchanged. No iOS dependency upgrade, +deployment-target change, or CocoaPods override is required for this release. + +The exposure and evaluation hooks require the core Datadog SDK to be initialized and +`DdFlags.enable()` to have completed. The RUM hook also uses a native Flags client to enforce +its evaluation telemetry opt-out policy, and requires RUM to be enabled separately. +The hooks do not initialize, shut down, or change consent for the shared native SDK. + +Tracking destinations are configured on the native SDK, not on individual hooks. The core +SDK's site selects the default endpoints. For custom intake routing, pass +`customExposureEndpoint` and/or `customEvaluationEndpoint` to `DdFlags.enable()` before +creating clients. Both accept base URLs; the RN bridge appends `/api/v2/exposures` and +`/api/v2/flagevaluation`, respectively. RUM's endpoint is configured separately when +initializing RUM. These overrides do not change configuration fetching. + +```tsx +import { DdFlags } from '@datadog/mobile-react-native'; +import { + composeDatadogTrackingHooks, + createDatadogExposureLoggingHook, + createDatadogEvaluationLoggingHook, + createDatadogRumTrackingHook +} from '@datadog/mobile-react-native-openfeature'; +import { + DatadogCoreProvider, + coreConfigurationFromString +} from '@datadog/mobile-react-native-openfeature/rules-based'; +import { OpenFeature } from '@openfeature/react-sdk'; + +// After initializing the core Datadog SDK: +await DdFlags.enable(); +const provider = new DatadogCoreProvider(); +provider.setConfiguration(coreConfigurationFromString(wire)); +await OpenFeature.setProviderAndWait('datadog-core', provider, { + targetingKey: 'user-123', country: 'US' +}); + +// Include only the tracking operations your application needs. +const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ clientName: 'datadog-core' }), + createDatadogEvaluationLoggingHook({ clientName: 'datadog-core' }), + createDatadogRumTrackingHook({ clientName: 'datadog-core' }) +); +await tracking.initialize(); +const client = OpenFeature.getClient('datadog-core'); +client.addHooks(...tracking.hooks); +const enabled = client.getBooleanValue('new-feature', false); + +// When this dedicated client no longer needs tracking: +client.clearHooks(); +await tracking.shutdown(); +``` + +`clearHooks()` removes all client-level hooks, so this cleanup assumes a client dedicated to +this tracking setup. It does not remove global or provider hooks. `shutdown()` deactivates +these tracking hooks without flushing or shutting down the shared native SDK. + +The composer also accepts hooks with optional synchronous or asynchronous lifecycle methods. +A lifecycle failure in one hook does not prevent the remaining hooks from initializing or +shutting down. + +Each hook submits its record immediately through an asynchronous native call. There is no +JavaScript batching, timer, or upload queue, and evaluation does not await native delivery. +Native consent handling, exposure deduplication, evaluation aggregation, storage, and upload +batching remain unchanged. Exposure logging respects `doLog`; evaluation logging also records +errors and coded defaults. RUM records the variant of successful evaluations through the +selected native Flags client, respecting its opt-out policy and RUM integration setting. +Use the same `clientName` for evaluation and RUM hooks to apply the same client's policy. +The hooks do not enable or synchronize per-subject opt-out; they preserve the native client's +existing policy. Exposure logging is not suppressed by evaluation telemetry opt-out. + +Use matching core and OpenFeature package releases containing the tracking APIs. If the core +package is missing a required tracking helper, `initialize()` warns once per hook and leaves +that hook inactive; flag evaluation continues. Repeated evaluations do not repeat the warning. + +iOS support for these independent hooks is deferred to a follow-up. The shared native bridge +methods reject direct iOS calls with `UNSUPPORTED_PLATFORM`; they do not report successful tracking. + ### Offline initialization Use `DatadogOfflineOpenFeatureProvider` to evaluate manually supplied precomputed or rules-based diff --git a/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts new file mode 100644 index 000000000..6fea7eafb --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts @@ -0,0 +1,76 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import * as DatadogSdk from '@datadog/mobile-react-native'; +import { OpenFeature } from '@openfeature/web-sdk'; +import { Platform } from 'react-native'; + +import { DatadogCoreProvider } from '../rules-based'; +import { + createDatadogExposureLoggingHook, + createDatadogEvaluationLoggingHook, + createDatadogRumTrackingHook +} from '../tracking'; + +import { + matchingContext, + precomputedConfiguration +} from './__utils__/coreConfiguration'; + +jest.mock('@datadog/mobile-react-native', () => ({ __esModule: true })); + +describe('tracking hook core compatibility', () => { + beforeEach(() => { + jest.replaceProperty(Platform, 'OS', 'android'); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + jest.restoreAllMocks(); + Reflect.deleteProperty(DatadogSdk, '__ddTrackFlagExposure'); + Reflect.deleteProperty(DatadogSdk, '__ddTrackFlagEvaluationEvent'); + Reflect.deleteProperty(DatadogSdk, '__ddTrackFlagRumEvaluation'); + }); + + it.each([ + [createDatadogExposureLoggingHook, '__ddTrackFlagExposure'], + [createDatadogEvaluationLoggingHook, '__ddTrackFlagEvaluationEvent'], + [createDatadogRumTrackingHook, '__ddTrackFlagRumEvaluation'] + ] as const)( + 'warns once and leaves evaluation working when %s is unavailable', + async (create, method) => { + const tracking = create(); + const provider = new DatadogCoreProvider(); + provider.setConfiguration(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const client = OpenFeature.getClient(); + client.addHooks(...tracking.hooks); + + await tracking.initialize(); + await tracking.initialize(); + for (let i = 0; i < 10; i++) { + expect(client.getBooleanValue('boolean-flag', false)).toBe( + true + ); + } + await tracking.shutdown(); + await tracking.initialize(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining(method) + ); + + const record = jest.fn(); + Object.assign(DatadogSdk, { [method]: record }); + await tracking.initialize(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(record).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledTimes(1); + } + ); +}); diff --git a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts new file mode 100644 index 000000000..a3daebdf5 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -0,0 +1,724 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { configurationToString } from '@datadog/flagging-core/rules-based'; +import { DdRum } from '@datadog/mobile-react-native'; +import { ErrorCode, OpenFeature } from '@openfeature/web-sdk'; +import { Platform } from 'react-native'; + +import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; +import type { DatadogTrackingHook, DatadogTrackingHooks } from '../index'; +import { + coreConfigurationFromString, + DatadogCoreProvider +} from '../rules-based'; +import { + composeDatadogTrackingHooks, + createDatadogEvaluationLoggingHook, + createDatadogExposureLoggingHook, + createDatadogRumTrackingHook +} from '../tracking'; + +import { + matchingContext, + precomputedConfiguration, + rulesConfiguration +} from './__utils__/coreConfiguration'; + +// Exercise the real evaluator, OpenFeature lifecycle, and JS bridge adapter. Only native +// delivery is mocked; its promises deliberately never resolve to detect accidental awaiting. +jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ + __esModule: true, + default: { + enable: jest.fn(), + setEvaluationContext: jest.fn(), + trackEvaluation: jest.fn(), + trackExposure: jest.fn(() => new Promise(() => {})), + trackEvaluationEvent: jest.fn(() => new Promise(() => {})), + trackRumEvaluation: jest.fn(() => new Promise(() => {})) + } +})); + +if (!NativeDdFlags) { + throw new Error('Native flags test double is required'); +} +const native = jest.mocked(NativeDdFlags); +const rum = native.trackRumEvaluation; +let sequence = 0; + +async function configuredClient( + tracking?: DatadogTrackingHooks, + rules = false +) { + const domain = `tracking-${++sequence}`; + const provider = new DatadogCoreProvider(); + provider.setConfiguration( + rules ? rulesConfiguration() : precomputedConfiguration() + ); + await OpenFeature.setProviderAndWait(domain, provider, matchingContext); + const client = OpenFeature.getClient(domain); + if (tracking) { + await tracking.initialize(); + client.addHooks(...tracking.hooks); + } + return { client, provider, domain }; +} + +describe('optional native tracking hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.replaceProperty(Platform, 'OS', 'android'); + jest.spyOn(DdRum, 'addFeatureFlagEvaluation').mockImplementation( + () => new Promise(() => {}) + ); + }); + + afterEach(async () => { + expect(DdRum.addFeatureFlagEvaluation).not.toHaveBeenCalled(); + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('does not track when no hooks are registered', async () => { + const { client } = await configuredClient(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + }); + + it.each([ + { rules: false, doLog: false }, + { rules: false, doLog: true }, + { rules: true, doLog: false }, + { rules: true, doLog: true } + ])( + 'preserves assigned DEFAULT results with rules=$rules and doLog=$doLog', + async ({ rules, doLog }) => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ), + rules + ); + const key = rules ? 'test-flag' : 'boolean-flag'; + if (rules) { + const configuration = rulesConfiguration(); + configuration.rules.response.flags[ + key + ].allocations[0].splits[0].reason = 4; + configuration.rules.response.flags[ + key + ].allocations[0].logExposureEvent = doLog; + provider.setConfiguration( + coreConfigurationFromString( + configurationToString(configuration) + ) + ); + } else { + const configuration = precomputedConfiguration(); + configuration.precomputed.response.data.attributes.flags[ + key + ].reason = 'DEFAULT'; + configuration.precomputed.response.data.attributes.flags[ + key + ].doLog = doLog; + provider.setConfiguration(configuration); + } + + expect(client.getBooleanDetails(key, false)).toMatchObject({ + value: true, + reason: 'DEFAULT', + variant: 'on' + }); + expect(native.trackExposure).toHaveBeenCalledTimes(doLog ? 1 : 0); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + key, + expect.objectContaining({ + allocationKey: 'allocation', + variationKey: 'on', + doLog + }), + 'user-1', + { country: 'US' }, + null + ); + expect(rum).toHaveBeenCalledTimes(1); + } + ); + + it('keeps an assignment-free coded default unassigned', async () => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ), + true + ); + const configuration = rulesConfiguration(); + configuration.rules.response.flags['test-flag'].allocations = []; + provider.setConfiguration( + coreConfigurationFromString(configurationToString(configuration)) + ); + + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: false, + reason: 'DEFAULT' + }); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'test-flag', + null, + 'user-1', + { country: 'US' }, + null + ); + }); + + it('treats ERROR without an error code as GENERAL despite assignment metadata', async () => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ) + ); + const configuration = precomputedConfiguration(); + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ].reason = 'ERROR'; + provider.setConfiguration(configuration); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: true, + reason: 'ERROR' + }); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'boolean-flag', + null, + 'user-1', + { country: 'US' }, + 'GENERAL' + ); + }); + + it.each([false, true])( + 'does not serialize unused object values with doLog: %s', + async doLog => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook() + ) + ); + const configuration = precomputedConfiguration(); + const flag = + configuration.precomputed.response.data.attributes.flags[ + 'object-flag' + ]; + flag.doLog = doLog; + flag.variationValue = { payload: 'x'.repeat(64 * 1024) }; + provider.setConfiguration(configuration); + const stringify = jest.spyOn(JSON, 'stringify'); + const value = client.getObjectValue('object-flag', {}); + expect(value).toEqual(flag.variationValue); + expect( + stringify.mock.calls.filter(([input]) => input === value) + ).toHaveLength(0); + expect(native.trackExposure).toHaveBeenCalledTimes(doLog ? 1 : 0); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'object-flag', + expect.objectContaining({ + allocationKey: 'allocation', + variationKey: 'settings', + doLog + }), + 'user-1', + { country: 'US' }, + null + ); + expect( + native.trackEvaluationEvent.mock.calls[0][2] + ).not.toHaveProperty('value'); + expect( + native.trackEvaluationEvent.mock.calls[0][2] + ).not.toHaveProperty('variationValue'); + } + ); + + it('does not warn or track for an iOS client without optional hooks', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { client } = await configuredClient(); + + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(console.warn).not.toHaveBeenCalled(); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(native.trackRumEvaluation).not.toHaveBeenCalled(); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + }); + + it.each([ + ['exposure', createDatadogExposureLoggingHook, 1, 0, 0], + ['evaluation', createDatadogEvaluationLoggingHook, 0, 1, 0], + ['RUM', createDatadogRumTrackingHook, 0, 0, 1] + ] as const)( + 'selects only the %s operation', + async (_name, create, exposures, evaluations, rumCalls) => { + const { client } = await configuredClient(create()); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(native.trackExposure).toHaveBeenCalledTimes(exposures); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes( + evaluations + ); + expect(rum).toHaveBeenCalledTimes(rumCalls); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + } + ); + + describe.each([false, true])('iOS with rules configuration: %s', rules => { + it.each([ + ['exposure', createDatadogExposureLoggingHook], + ['evaluation', createDatadogEvaluationLoggingHook], + ['RUM', createDatadogRumTrackingHook] + ] as const)( + 'disables %s without affecting evaluation', + async (_name, create) => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const tracking = create(); + const { client } = await configuredClient(tracking, rules); + const flagKey = rules ? 'test-flag' : 'boolean-flag'; + + await tracking.initialize(); + for (let i = 0; i < 10; i++) { + expect(client.getBooleanValue(flagKey, false)).toBe(true); + } + await tracking.shutdown(); + await tracking.initialize(); + expect(client.getBooleanValue(flagKey, false)).toBe(true); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('supported only on Android') + ); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(native.trackRumEvaluation).not.toHaveBeenCalled(); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + } + ); + }); + + it('submits each record immediately, without JS batching or deduplication', async () => { + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ clientName: 'native-owner' }), + createDatadogEvaluationLoggingHook({ clientName: 'native-owner' }), + createDatadogRumTrackingHook({ clientName: 'native-owner' }) + ); + const { client } = await configuredClient(tracking); + jest.useFakeTimers(); + for (let i = 0; i < 10; i++) { + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(native.trackExposure).toHaveBeenCalledTimes(i + 1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(i + 1); + expect(rum).toHaveBeenCalledTimes(i + 1); + } + expect(jest.getTimerCount()).toBe(0); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.trackExposure).toHaveBeenLastCalledWith( + 'native-owner', + 'boolean-flag', + expect.objectContaining({ + allocationKey: 'allocation', + variationKey: 'on', + doLog: true + }), + 'user-1', + { country: 'US' } + ); + expect(rum).toHaveBeenLastCalledWith( + 'native-owner', + 'boolean-flag', + 'on', + 'user-1', + { country: 'US' } + ); + }); + + it('does not suppress evaluation or RUM when doLog is false', async () => { + const { client } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ) + ); + expect(client.getStringValue('string-flag', '')).toBe('hello'); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'string-flag', + expect.objectContaining({ doLog: false, variationKey: 'greeting' }), + 'user-1', + { country: 'US' }, + null + ); + expect(rum).toHaveBeenCalledWith( + 'default', + 'string-flag', + 'greeting', + 'user-1', + { country: 'US' } + ); + }); + + it('records errors once without an assignment, exposure, or RUM event', async () => { + const { client } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ) + ); + expect(client.getBooleanDetails('missing', false).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + expect(client.getNumberDetails('boolean-flag', 42).errorCode).toBe( + ErrorCode.TYPE_MISMATCH + ); + expect(native.trackEvaluationEvent.mock.calls).toEqual([ + [ + 'default', + 'missing', + null, + 'user-1', + { country: 'US' }, + ErrorCode.FLAG_NOT_FOUND + ], + [ + 'default', + 'boolean-flag', + null, + 'user-1', + { country: 'US' }, + ErrorCode.TYPE_MISMATCH + ] + ]); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + }); + + it('captures each rules evaluation context without updating the native client context', async () => { + const { client, domain } = await configuredClient( + createDatadogEvaluationLoggingHook(), + true + ); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.setContext(domain, { + targetingKey: 'user-2', + country: 'CA', + nested: { ignored: true } + }); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect( + native.trackEvaluationEvent.mock.calls.map(call => call.slice(3, 5)) + ).toEqual([ + ['user-1', { country: 'US' }], + ['user-2', { country: 'CA' }] + ]); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('passes opt-out context to the selected native RUM tracking client', async () => { + const { client, domain } = await configuredClient( + createDatadogRumTrackingHook({ clientName: 'opt-out-owner' }), + true + ); + await OpenFeature.setContext(domain, { + ...matchingContext, + dd_evaluation_telemetry_opt_out: true + }); + + expect(client.getBooleanValue('test-flag', false)).toBe(true); + expect(rum).toHaveBeenCalledWith( + 'opt-out-owner', + 'test-flag', + 'on', + 'user-1', + { country: 'US', dd_evaluation_telemetry_opt_out: true } + ); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('preserves split serial IDs for native exposure deduplication', async () => { + const { client, provider } = await configuredClient( + createDatadogExposureLoggingHook() + ); + const configuration = precomputedConfiguration(); + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ].serialId = 123; + provider.setConfiguration(configuration); + client.getBooleanValue('boolean-flag', false); + expect(native.trackExposure.mock.calls[0][2]).toMatchObject({ + serialId: '123' + }); + }); + + it('activates and stops only its own hooks, without managing the shared native SDK', async () => { + const exposure = createDatadogExposureLoggingHook(); + const evaluation = createDatadogEvaluationLoggingHook(); + const { client } = await configuredClient(evaluation); + client.addHooks(...exposure.hooks); + client.getBooleanValue('boolean-flag', false); + expect(native.trackExposure).not.toHaveBeenCalled(); + await exposure.initialize(); + await exposure.initialize(); + client.getBooleanValue('boolean-flag', false); + await exposure.shutdown(); + await exposure.shutdown(); + client.getBooleanValue('boolean-flag', false); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(3); + expect(native.enable).not.toHaveBeenCalled(); + }); + + it('isolates synchronous bridge errors and rejected native promises from evaluation', async () => { + native.trackExposure.mockImplementationOnce(() => { + throw new Error('bridge unavailable'); + }); + native.trackEvaluationEvent.mockRejectedValueOnce( + new Error('native unavailable') + ); + rum.mockRejectedValueOnce(new Error('RUM unavailable')); + const { client } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ) + ); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + await Promise.resolve(); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(1); + expect(rum).toHaveBeenCalledTimes(1); + }); + + it('unregisters tracking with the documented clearHooks and shutdown sequence', async () => { + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ); + const { client } = await configuredClient(tracking); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + client.clearHooks(); + await tracking.shutdown(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(1); + expect(rum).toHaveBeenCalledTimes(1); + expect(native.enable).not.toHaveBeenCalled(); + }); + + it('restarts the same tracking controller without duplicate registrations', async () => { + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ); + const { client } = await configuredClient(tracking); + const expectRecords = (count: number) => { + expect(native.trackExposure).toHaveBeenCalledTimes(count); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(count); + expect(rum).toHaveBeenCalledTimes(count); + }; + + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expectRecords(1); + client.clearHooks(); + await tracking.shutdown(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expectRecords(1); + + await tracking.initialize(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expectRecords(1); + client.addHooks(...tracking.hooks); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expectRecords(2); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expectRecords(3); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('routes named clients independently and keeps one active when the other stops', async () => { + const firstTracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ clientName: 'native-first' }), + createDatadogEvaluationLoggingHook({ clientName: 'native-first' }) + ); + const secondTracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ clientName: 'native-second' }), + createDatadogEvaluationLoggingHook({ clientName: 'native-second' }) + ); + const first = await configuredClient(firstTracking, true); + const second = await configuredClient(secondTracking, true); + await OpenFeature.setContext(second.domain, { + targetingKey: 'user-2', + country: 'US', + tier: 'premium' + }); + + expect(first.client.getBooleanValue('test-flag', false)).toBe(true); + expect(second.client.getBooleanValue('test-flag', false)).toBe(true); + first.client.clearHooks(); + await firstTracking.shutdown(); + expect(first.client.getBooleanValue('test-flag', false)).toBe(true); + expect(second.client.getBooleanValue('test-flag', false)).toBe(true); + + const assignment = expect.objectContaining({ + allocationKey: 'allocation', + variationKey: 'on', + doLog: true + }); + const expectedRecords = [ + [ + 'native-first', + 'test-flag', + assignment, + 'user-1', + { country: 'US' } + ], + [ + 'native-second', + 'test-flag', + assignment, + 'user-2', + { country: 'US', tier: 'premium' } + ], + [ + 'native-second', + 'test-flag', + assignment, + 'user-2', + { country: 'US', tier: 'premium' } + ] + ]; + expect(native.trackExposure.mock.calls).toEqual(expectedRecords); + expect(native.trackEvaluationEvent.mock.calls).toEqual( + expectedRecords.map(record => [...record, null]) + ); + expect(rum).not.toHaveBeenCalled(); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + + describe('composition lifecycle', () => { + it('accepts lifecycle-free hooks and optional synchronous methods without starting them', async () => { + const firstHook = { finally: jest.fn() }; + const secondHook = { finally: jest.fn() }; + const initialize = jest.fn(); + const shutdown = jest.fn(); + const hooksOnly: DatadogTrackingHook = { hooks: [firstHook] }; + const tracking = composeDatadogTrackingHooks( + hooksOnly, + { hooks: [secondHook], initialize }, + { hooks: [], shutdown } + ); + + expect(tracking.hooks).toEqual([firstHook, secondHook]); + expect(initialize).not.toHaveBeenCalled(); + expect(shutdown).not.toHaveBeenCalled(); + const { client } = await configuredClient(tracking); + expect(initialize).toHaveBeenCalledTimes(1); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(firstHook.finally).toHaveBeenCalledTimes(1); + expect(secondHook.finally).toHaveBeenCalledTimes(1); + client.clearHooks(); + await expect(tracking.shutdown()).resolves.toBeUndefined(); + expect(shutdown).toHaveBeenCalledTimes(1); + }); + + it('supports empty composition', async () => { + const tracking = composeDatadogTrackingHooks(); + expect(tracking.hooks).toEqual([]); + await expect(tracking.initialize()).resolves.toBeUndefined(); + await expect(tracking.shutdown()).resolves.toBeUndefined(); + }); + + it.each(['initialize', 'shutdown'] as const)( + 'isolates synchronous throws and promise rejections during %s', + async phase => { + const throws = jest.fn(() => { + throw new Error('synchronous lifecycle failure'); + }); + const rejects = jest.fn(() => + Promise.reject(new Error('asynchronous lifecycle failure')) + ); + const succeeds = jest.fn(() => Promise.resolve()); + const tracking = composeDatadogTrackingHooks( + { hooks: [], [phase]: throws }, + { hooks: [], [phase]: rejects }, + { hooks: [] }, + { hooks: [], [phase]: succeeds } + ); + + await expect(tracking[phase]()).resolves.toBeUndefined(); + expect(throws).toHaveBeenCalledTimes(1); + expect(rejects).toHaveBeenCalledTimes(1); + expect(succeeds).toHaveBeenCalledTimes(1); + } + ); + + it.each(['initialize', 'shutdown'] as const)( + 'waits for asynchronous %s methods to settle', + async phase => { + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const tracking = composeDatadogTrackingHooks({ + hooks: [], + [phase]: () => pending + }); + let completed = false; + const lifecycle = tracking[phase]().then(() => { + completed = true; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + finish(); + await lifecycle; + expect(completed).toBe(true); + } + ); + }); +}); diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 83da59a0d..f80ed0063 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -20,3 +20,14 @@ export { }; export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; export type { FlagsConfiguration } from '@datadog/flagging-core'; +export { + createDatadogExposureLoggingHook, + createDatadogEvaluationLoggingHook, + createDatadogRumTrackingHook, + composeDatadogTrackingHooks +} from './tracking'; +export type { + DatadogTrackingHook, + DatadogTrackingHooks, + DatadogTrackingHooksOptions +} from './tracking'; diff --git a/packages/react-native-openfeature/src/tracking.ts b/packages/react-native-openfeature/src/tracking.ts new file mode 100644 index 000000000..a067fcab1 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking.ts @@ -0,0 +1,201 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import * as DatadogSdk from '@datadog/mobile-react-native'; +import type { + EvaluationDetails, + FlagValue, + Hook, + HookContext +} from '@openfeature/web-sdk'; +import { Platform } from 'react-native'; + +import { toDdContext } from './mappers'; + +export interface DatadogTrackingHooksOptions { + /** Named native Flags client used for tracking. Defaults to 'default'. */ + clientName?: string; +} + +export interface DatadogTrackingHooks { + hooks: Hook[]; + initialize(): Promise; + shutdown(): Promise; +} + +export interface DatadogTrackingHook { + hooks: Hook[]; + initialize?(): Promise | void; + shutdown?(): Promise | void; +} + +/** Android only. Logs exposures through native storage, consent handling, and deduplication. */ +export function createDatadogExposureLoggingHook( + options: DatadogTrackingHooksOptions = {} +): DatadogTrackingHooks { + const clientName = options.clientName ?? 'default'; + return createHook('__ddTrackFlagExposure', (context, details) => { + if (details.flagMetadata?.doLog !== true) { + return; + } + const flag = toAssignment(details); + if (!flag?.doLog) { + return; + } + DatadogSdk.__ddTrackFlagExposure( + clientName, + context.flagKey, + flag, + toDdContext(context.context) + ); + }); +} + +/** Android only. Logs every evaluation, including errors/defaults, through native aggregation. */ +export function createDatadogEvaluationLoggingHook( + options: DatadogTrackingHooksOptions = {} +): DatadogTrackingHooks { + const clientName = options.clientName ?? 'default'; + return createHook('__ddTrackFlagEvaluationEvent', (context, details) => { + DatadogSdk.__ddTrackFlagEvaluationEvent( + clientName, + context.flagKey, + toAssignment(details), + toDdContext(context.context), + details.errorCode ?? (details.reason === 'ERROR' ? 'GENERAL' : null) + ); + }); +} + +/** Android only. Adds successful evaluations to the current native RUM view. */ +export function createDatadogRumTrackingHook( + options: DatadogTrackingHooksOptions = {} +): DatadogTrackingHooks { + const clientName = options.clientName ?? 'default'; + return createHook('__ddTrackFlagRumEvaluation', (context, details) => { + if ( + !details.errorCode && + details.reason !== 'ERROR' && + details.variant != null + ) { + DatadogSdk.__ddTrackFlagRumEvaluation( + clientName, + context.flagKey, + details.variant, + toDdContext(context.context) + ); + } + }); +} + +export function composeDatadogTrackingHooks( + ...trackingHooks: DatadogTrackingHook[] +): DatadogTrackingHooks { + return { + hooks: trackingHooks.flatMap(tracking => tracking.hooks), + initialize: async () => { + await Promise.all( + trackingHooks.map(tracking => + runTrackingLifecycleOperation(() => tracking.initialize?.()) + ) + ); + }, + shutdown: async () => { + await Promise.all( + trackingHooks.map(tracking => + runTrackingLifecycleOperation(() => tracking.shutdown?.()) + ) + ); + } + }; +} + +function runTrackingLifecycleOperation( + operation: () => Promise | void | undefined +): Promise { + try { + return Promise.resolve(operation()).catch(() => {}); + } catch { + return Promise.resolve(); + } +} + +function createHook( + method: + | '__ddTrackFlagExposure' + | '__ddTrackFlagEvaluationEvent' + | '__ddTrackFlagRumEvaluation', + record: ( + context: HookContext, + details: EvaluationDetails + ) => void +): DatadogTrackingHooks { + let active = false; + let compatibilityWarningLogged = false; + return { + hooks: [ + { + finally: (context, details) => { + if (!active) { + return; + } + try { + record(context, details); + } catch { + // Optional tracking must never change the evaluation result. + } + } + } + ], + initialize: async () => { + if (Platform.OS !== 'android') { + active = false; + if (!compatibilityWarningLogged) { + compatibilityWarningLogged = true; + // eslint-disable-next-line no-console + console.warn( + 'DATADOG: Independent flag tracking hooks are currently supported only on Android. This hook is disabled; flag evaluation is unaffected.' + ); + } + return; + } + active = typeof DatadogSdk[method] === 'function'; + if (!active && !compatibilityWarningLogged) { + compatibilityWarningLogged = true; + // Older core packages may not expose InternalLog either. + // eslint-disable-next-line no-console + console.warn( + `DATADOG: Tracking hook disabled because the core SDK does not provide ${method}. Install matching versions of @datadog/mobile-react-native and @datadog/mobile-react-native-openfeature that support tracking hooks, then rebuild the native app.` + ); + } + }, + // Hooks do not own the shared native SDK, its consent state, or its upload pipeline. + shutdown: async () => { + active = false; + } + }; +} + +function toAssignment(details: EvaluationDetails) { + const allocationKey = details.flagMetadata?.allocationKey; + if ( + details.errorCode || + details.reason === 'ERROR' || + typeof allocationKey !== 'string' || + typeof details.variant !== 'string' + ) { + return null; + } + const serialId = details.flagMetadata?.__dd_split_serial_id; + return { + allocationKey, + variationKey: details.variant, + doLog: details.flagMetadata?.doLog === true, + ...(typeof serialId === 'number' || typeof serialId === 'string' + ? { serialId: String(serialId) } + : {}) + }; +} diff --git a/packages/react-native-session-replay/android/build.gradle b/packages/react-native-session-replay/android/build.gradle index 8c8fee867..79087e5a3 100644 --- a/packages/react-native-session-replay/android/build.gradle +++ b/packages/react-native-session-replay/android/build.gradle @@ -215,8 +215,8 @@ dependencies { api "com.facebook.react:react-android:$reactNativeVersion" } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.datadoghq:dd-sdk-android-session-replay:3.15.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.15.0" + implementation "com.datadoghq:dd-sdk-android-session-replay:3.16.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.16.0" implementation project(path: ':datadog_mobile-react-native') testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-webview/android/build.gradle b/packages/react-native-webview/android/build.gradle index 7790c4335..7ccde2b05 100644 --- a/packages/react-native-webview/android/build.gradle +++ b/packages/react-native-webview/android/build.gradle @@ -196,7 +196,7 @@ dependencies { implementation "com.facebook.react:react-android:$reactNativeVersion" } - implementation "com.datadoghq:dd-sdk-android-webview:3.15.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.16.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native')