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..b27461602 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,59 @@ class DdFlagsImplementation( promise.resolve(null) } + @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) + } + + @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) + } + + @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 +219,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..2c3030ecf 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,139 @@ 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", + rawFlag(), + "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", + rawFlag(), + "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..315eaf7ce 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -94,6 +94,9 @@ public class DdFlagsImplementation: NSObject { case .networkError: errorCode = "NETWORK_ERROR" errorMessage = "A network error occurred while fetching feature flags for client '\(clientName)'." + case .initializationTimedOut: + errorCode = "CLIENT_NOT_INITIALIZED" + errorMessage = "Flags initialization timed out for client '\(clientName)'." } reject(errorCode, errorMessage, error as NSError) } @@ -118,6 +121,45 @@ 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) { + guard let client = getClient(name: clientName) as? FlagsClientInternal else { + reject("CLIENT_NOT_INITIALIZED", "Flags client '\(clientName)' is not properly initialized.", nil) + return + } + guard let assignment = rawFlag.asFlagAssignment() else { + reject("INVALID_FLAG_ASSIGNMENT", "Failed to parse correct flag assignment from the provided raw flag data.", nil) + return + } + client.sendFlagExposure(key: key, assignment: assignment, context: buildEvaluationContext(targetingKey: targetingKey, attributes: attributes)) + resolve(nil) + } + + @objc + public func trackEvaluationEvent(_ clientName: String, key: String, rawFlag: NSDictionary?, targetingKey: String, attributes: NSDictionary, errorCode: String?, resolve: @escaping RCTPromiseResolve, reject: @escaping RCTPromiseReject) { + guard let client = getClient(name: clientName) as? FlagsClientInternal else { + reject("CLIENT_NOT_INITIALIZED", "Flags client '\(clientName)' is not properly initialized.", nil) + return + } + let assignment = rawFlag?.asFlagAssignment() + guard rawFlag == nil || assignment != nil else { + reject("INVALID_FLAG_ASSIGNMENT", "Failed to parse correct flag assignment from the provided raw flag data.", nil) + return + } + client.sendFlagEvaluationEvent(key: key, assignment: assignment, context: buildEvaluationContext(targetingKey: targetingKey, attributes: attributes), errorCode: errorCode) + resolve(nil) + } + + @objc + public func trackRumEvaluation(_ clientName: String, key: String, variant: String, targetingKey: String, attributes: NSDictionary, resolve: @escaping RCTPromiseResolve, reject: @escaping RCTPromiseReject) { + guard let client = getClient(name: clientName) as? FlagsClientInternal else { + reject("CLIENT_NOT_INITIALIZED", "Flags client '\(clientName)' is not properly initialized.", nil) + return + } + client.sendFlagRUMEvaluation(key: key, variant: variant, context: buildEvaluationContext(targetingKey: targetingKey, attributes: attributes)) + resolve(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] ?? [:] @@ -150,12 +192,17 @@ extension NSDictionary { if let customExposureEndpoint = object(forKey: "customExposureEndpoint") as? String { customExposureEndpointURL = URL(string: "\(customExposureEndpoint)/api/v2/exposures" as String) } + var customEvaluationEndpointURL: URL? = nil + if let customEvaluationEndpoint = object(forKey: "customEvaluationEndpoint") as? String { + customEvaluationEndpointURL = URL(string: "\(customEvaluationEndpoint)/api/v2/flagevaluation" as String) + } return Flags.Configuration( gracefulModeEnabled: gracefulModeEnabled, customFlagsEndpoint: customFlagsEndpointURL, customExposureEndpoint: customExposureEndpointURL, trackExposures: trackExposures ?? true, + customEvaluationEndpoint: customEvaluationEndpointURL, rumIntegrationEnabled: rumIntegrationEnabled ?? true ) } @@ -213,7 +260,8 @@ extension NSDictionary { variationKey: variationKey, variation: variation, reason: reason, - doLog: doLog + doLog: doLog, + serialID: (object(forKey: "serialId") as? String).flatMap(Int.init) ) } } diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index 3fca72ddc..fca070449 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -174,6 +174,103 @@ class DdFlagsTests: XCTestCase { waitForExpectations(timeout: 1.0, handler: nil) } + func testTrackExposureIsIndependentAndPreservesSerialID() { + let client = MockFlagsClient() + implementation.clientProviders["tracking"] = { client } + let flag: NSDictionary = [ + "allocationKey": "allocation", "variationKey": "on", "reason": "TARGETING_MATCH", + "doLog": true, "value": true, "serialId": "9007199254740993" + ] + var resolved = false + implementation.trackExposure("tracking", key: "flag", rawFlag: flag, targetingKey: "user", attributes: ["country": "US"], resolve: { _ in + resolved = true + }, reject: { _, _, _ in XCTFail("Should resolve") }) + + XCTAssertTrue(resolved) + XCTAssertEqual(client.trackedExposure?.key, "flag") + XCTAssertEqual(client.trackedExposure?.assignment.serialID, 9_007_199_254_740_993) + XCTAssertEqual(client.trackedExposure?.context.targetingKey, "user") + XCTAssertEqual(client.trackedExposure?.context.attributes["country"], .string("US")) + XCTAssertNil(client.trackedEvaluation) + XCTAssertNil(client.trackedEvaluationEvent) + XCTAssertNil(client.lastEvaluationContext) + } + + func testTrackEvaluationEventIsIndependent() { + let client = MockFlagsClient() + implementation.clientProviders["tracking"] = { client } + let flag: NSDictionary = [ + "allocationKey": "allocation", "variationKey": "off", "reason": "STATIC", + "doLog": false, "value": false + ] + var resolved = false + implementation.trackEvaluationEvent("tracking", key: "flag", rawFlag: flag, targetingKey: "user", attributes: ["country": "US"], errorCode: nil, resolve: { _ in + resolved = true + }, reject: { _, _, _ in XCTFail("Should resolve") }) + + XCTAssertTrue(resolved) + XCTAssertEqual(client.trackedEvaluationEvent?.key, "flag") + XCTAssertEqual(client.trackedEvaluationEvent?.assignment?.variationKey, "off") + XCTAssertEqual(client.trackedEvaluationEvent?.context.targetingKey, "user") + XCTAssertEqual(client.trackedEvaluationEvent?.context.attributes["country"], .string("US")) + XCTAssertNil(client.trackedEvaluationEvent?.errorCode) + XCTAssertNil(client.trackedExposure) + XCTAssertNil(client.trackedEvaluation) + XCTAssertNil(client.lastEvaluationContext) + } + + func testTrackRumEvaluationIsIndependentAndPreservesOptOutContext() { + let client = MockFlagsClient() + implementation.clientProviders["tracking"] = { client } + var resolved = false + + implementation.trackRumEvaluation("tracking", key: "flag", variant: "on", targetingKey: "user", attributes: ["dd_evaluation_telemetry_opt_out": true], resolve: { _ in + resolved = true + }, reject: { _, _, _ in XCTFail("Should resolve") }) + + XCTAssertTrue(resolved) + XCTAssertEqual(client.trackedRUMEvaluation?.key, "flag") + XCTAssertEqual(client.trackedRUMEvaluation?.variant, "on") + XCTAssertEqual(client.trackedRUMEvaluation?.context.targetingKey, "user") + XCTAssertEqual(client.trackedRUMEvaluation?.context.attributes["dd_evaluation_telemetry_opt_out"], .bool(true)) + XCTAssertNil(client.trackedExposure) + XCTAssertNil(client.trackedEvaluation) + XCTAssertNil(client.trackedEvaluationEvent) + XCTAssertNil(client.lastEvaluationContext) + } + + func testTrackEvaluationEventWithoutAssignment() { + let client = MockFlagsClient() + implementation.clientProviders["tracking"] = { client } + for errorCode in [nil, "FLAG_NOT_FOUND"] as [String?] { + var resolved = false + implementation.trackEvaluationEvent("tracking", key: "flag", rawFlag: nil, targetingKey: "user", attributes: [:], errorCode: errorCode, resolve: { _ in + resolved = true + }, reject: { _, _, _ in XCTFail("Should resolve") }) + XCTAssertTrue(resolved) + XCTAssertEqual(client.trackedEvaluationEvent?.key, "flag") + XCTAssertNil(client.trackedEvaluationEvent?.assignment) + XCTAssertEqual(client.trackedEvaluationEvent?.errorCode, errorCode) + } + XCTAssertNil(client.trackedExposure) + XCTAssertNil(client.trackedEvaluation) + } + + func testIndependentTrackingRejectsMalformedAssignments() { + let client = MockFlagsClient() + implementation.clientProviders["tracking"] = { client } + var rejectionCount = 0 + let reject: RCTPromiseReject = { code, _, _ in + XCTAssertEqual(code, "INVALID_FLAG_ASSIGNMENT") + rejectionCount += 1 + } + implementation.trackExposure("tracking", key: "flag", rawFlag: [:], targetingKey: "user", attributes: [:], resolve: { _ in XCTFail("Should reject") }, reject: reject) + implementation.trackEvaluationEvent("tracking", key: "flag", rawFlag: [:], targetingKey: "user", attributes: [:], errorCode: nil, resolve: { _ in XCTFail("Should reject") }, reject: reject) + XCTAssertEqual(rejectionCount, 2) + XCTAssertNil(client.trackedExposure) + XCTAssertNil(client.trackedEvaluationEvent) + } + // MARK: - AnyValue Tests func testAnyValueWrapUnwrapNull() { @@ -316,7 +413,8 @@ class DdFlagsTests: XCTestCase { "trackExposures": false, "rumIntegrationEnabled": false, "customFlagsEndpoint": "https://flags.example.com", - "customExposureEndpoint": "https://exposure.example.com" + "customExposureEndpoint": "https://exposure.example.com", + "customEvaluationEndpoint": "https://evaluation.example.com" ] let config = configDict.asFlagsConfiguration() @@ -326,6 +424,7 @@ class DdFlagsTests: XCTestCase { XCTAssertEqual(config?.rumIntegrationEnabled, false) XCTAssertEqual(config?.customFlagsEndpoint?.absoluteString, "https://flags.example.com/precompute-assignments") XCTAssertEqual(config?.customExposureEndpoint?.absoluteString, "https://exposure.example.com/api/v2/exposures") + XCTAssertEqual(config?.customEvaluationEndpoint?.absoluteString, "https://evaluation.example.com/api/v2/flagevaluation") } func testConfigurationParsingDefaults() { @@ -337,6 +436,20 @@ class DdFlagsTests: XCTestCase { XCTAssertEqual(config?.rumIntegrationEnabled, true) XCTAssertNil(config?.customFlagsEndpoint) XCTAssertNil(config?.customExposureEndpoint) + XCTAssertNil(config?.customEvaluationEndpoint) + } + + func testConfigurationParsingEvaluationEndpointOnly() { + let configDict: NSDictionary = [ + "enabled": true, + "customEvaluationEndpoint": "https://evaluation.example.com/proxy" + ] + let config = configDict.asFlagsConfiguration() + + XCTAssertNotNil(config) + XCTAssertEqual(config?.customEvaluationEndpoint?.absoluteString, "https://evaluation.example.com/proxy/api/v2/flagevaluation") + XCTAssertNil(config?.customFlagsEndpoint) + XCTAssertNil(config?.customExposureEndpoint) } func testConfigurationParsingDisabled() { @@ -394,6 +507,7 @@ class DdFlagsTests: XCTestCase { private class FlagsTestCore: DatadogCoreProtocol { private var features: [String: DatadogFeature] = [:] + var remoteConfiguration: RemoteConfiguration? { nil } func register(feature: T) throws where T : DatadogFeature { features[T.name] = feature @@ -422,6 +536,9 @@ private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { var lastEvaluationContext: FlagsEvaluationContext? var trackedEvaluation: (key: String, assignment: FlagAssignment, context: FlagsEvaluationContext)? + var trackedExposure: (key: String, assignment: FlagAssignment, context: FlagsEvaluationContext)? + var trackedEvaluationEvent: (key: String, assignment: FlagAssignment?, context: FlagsEvaluationContext, errorCode: String?)? + var trackedRUMEvaluation: (key: String, variant: String, context: FlagsEvaluationContext)? func setEvaluationContext(_ context: DatadogFlags.FlagsEvaluationContext, completion: @escaping (Result) -> Void) { lastEvaluationContext = context @@ -439,4 +556,16 @@ private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { func sendFlagEvaluation(key: String, assignment: DatadogFlags.FlagAssignment, context: DatadogFlags.FlagsEvaluationContext) { trackedEvaluation = (key, assignment, context) } + + func sendFlagExposure(key: String, assignment: FlagAssignment, context: FlagsEvaluationContext) { + trackedExposure = (key, assignment, context) + } + + func sendFlagEvaluationEvent(key: String, assignment: FlagAssignment?, context: FlagsEvaluationContext, errorCode: String?) { + trackedEvaluationEvent = (key, assignment, context, errorCode) + } + + func sendFlagRUMEvaluation(key: String, variant: String, context: FlagsEvaluationContext) { + trackedRUMEvaluation = (key, variant, context) + } } 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..7a69d4041 --- /dev/null +++ b/packages/core/src/flags/tracking.ts @@ -0,0 +1,93 @@ +/* + * 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'; + +/** @internal Used by the matching-version OpenFeature tracking hooks. */ +export function __ddTrackFlagExposure( + clientName: string, + key: string, + flag: FlagCacheEntry, + 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: FlagCacheEntry | 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..265d9a38b 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; + /** + * Custom server URL for sending aggregated flag evaluation data. + * + * 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/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index e3e693c26..0d4187eb5 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -338,6 +338,89 @@ precomputed-only `configurationFromString` helper. `Error`. Loading a usable configuration after an error emits `Ready`, followed by `ConfigurationChanged`. +### Optional tracking with DatadogCoreProvider + +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. + +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. + +These hooks depend on the separate native tracking APIs being introduced in the iOS and Android +SDKs. Until those APIs are released and the RN native dependencies are updated, test them with +the corresponding local native SDK builds; the currently pinned native releases do not contain +these APIs. + ### 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..dca2bf94e --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { 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.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..c733aeb92 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -0,0 +1,505 @@ +/* + * 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 { DdRum } from '@datadog/mobile-react-native'; +import { ErrorCode, OpenFeature } from '@openfeature/web-sdk'; + +import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; +import type { DatadogTrackingHook, DatadogTrackingHooks } from '../index'; +import { 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.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([ + ['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(); + } + ); + + 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({ + value: true, + allocationKey: 'allocation', + variationKey: 'on', + doLog: true, + variationType: 'boolean', + variationValue: '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({ + value: true, + 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..198aa8011 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking.ts @@ -0,0 +1,199 @@ +/* + * 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 { 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; +} + +/** 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) => { + const flag = toAssignment(context, details); + if (!flag?.doLog) { + return; + } + DatadogSdk.__ddTrackFlagExposure( + clientName, + context.flagKey, + flag, + toDdContext(context.context) + ); + }); +} + +/** 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(context, details), + toDdContext(context.context), + details.errorCode ?? (details.reason === 'ERROR' ? 'GENERAL' : null) + ); + }); +} + +/** 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 () => { + 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( + context: HookContext, + details: EvaluationDetails +) { + const allocationKey = details.flagMetadata?.allocationKey; + if ( + details.errorCode || + details.reason === 'ERROR' || + details.reason === 'DEFAULT' || + typeof allocationKey !== 'string' || + typeof details.variant !== 'string' + ) { + return null; + } + const serialId = details.flagMetadata?.__dd_split_serial_id; + return { + key: context.flagKey, + value: details.value, + allocationKey, + variationKey: details.variant, + variationType: context.flagValueType, + variationValue: + typeof details.value === 'object' + ? JSON.stringify(details.value) + : String(details.value), + reason: details.reason ?? 'UNKNOWN', + doLog: details.flagMetadata?.doLog === true, + extraLogging: {}, + ...(typeof serialId === 'number' || typeof serialId === 'string' + ? { serialId: String(serialId) } + : {}) + }; +}