From 5b021fcd29be8bbd81aa3dc3a03632bbd4fc8d9a Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 14:58:10 -0700 Subject: [PATCH 1/4] feat(openfeature): add independent native tracking hooks --- .../reactnative/DdFlagsImplementation.kt | 36 ++ .../kotlin/com/datadog/reactnative/DdFlags.kt | 33 ++ .../kotlin/com/datadog/reactnative/DdFlags.kt | 33 ++ .../reactnative/DdFlagsImplementationTest.kt | 113 ++++++ packages/core/ios/Sources/DdFlags.mm | 31 ++ .../ios/Sources/DdFlagsImplementation.swift | 35 +- packages/core/ios/Tests/DdFlagsTests.swift | 88 +++++ packages/core/src/flags/tracking.ts | 74 ++++ packages/core/src/index.tsx | 5 + packages/core/src/specs/NativeDdFlags.ts | 16 + packages/react-native-openfeature/README.md | 67 ++++ .../__tests__/tracking.integration.test.ts | 373 ++++++++++++++++++ .../react-native-openfeature/src/index.ts | 11 + .../react-native-openfeature/src/tracking.ts | 185 +++++++++ 14 files changed, 1099 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/flags/tracking.ts create mode 100644 packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts create mode 100644 packages/react-native-openfeature/src/tracking.ts 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..90c1601eb 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,42 @@ 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) + } + internal companion object { internal const val NAME = "DdFlags" } 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..f02678df1 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,37 @@ class DdFlags( ) { implementation.trackEvaluation(clientName, key, rawFlag, 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..56da9c639 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,37 @@ class DdFlags(reactContext: ReactApplicationContext) : ReactContextBaseJavaModul ) { implementation.trackEvaluation(clientName, key, rawFlag, 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..4bb6e9706 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) @@ -93,4 +102,108 @@ 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) + } + } + } + + 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..d855eaa8f 100644 --- a/packages/core/ios/Sources/DdFlags.mm +++ b/packages/core/ios/Sources/DdFlags.mm @@ -44,6 +44,29 @@ @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]; +} + // 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 +103,12 @@ - (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]; +} @end diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 34020f410..47e89814c 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,35 @@ 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) + } + /// 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] ?? [:] @@ -213,7 +245,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..d682a8c0c 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -174,6 +174,83 @@ 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 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() { @@ -394,6 +471,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 +500,8 @@ 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?)? func setEvaluationContext(_ context: DatadogFlags.FlagsEvaluationContext, completion: @escaping (Result) -> Void) { lastEvaluationContext = context @@ -439,4 +519,12 @@ 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) + } } diff --git a/packages/core/src/flags/tracking.ts b/packages/core/src/flags/tracking.ts new file mode 100644 index 000000000..0e6b4c954 --- /dev/null +++ b/packages/core/src/flags/tracking.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 { 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 + ) + ); +} + +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/index.tsx b/packages/core/src/index.tsx index fc704bc81..fac48b3f3 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -71,6 +71,11 @@ 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 +} from './flags/tracking'; + export { DatadogProvider, DatadogProviderConfiguration, diff --git a/packages/core/src/specs/NativeDdFlags.ts b/packages/core/src/specs/NativeDdFlags.ts index 5c8afd010..e9bb54efa 100644 --- a/packages/core/src/specs/NativeDdFlags.ts +++ b/packages/core/src/specs/NativeDdFlags.ts @@ -29,6 +29,22 @@ 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; } // 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..9e292fe24 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -338,6 +338,73 @@ 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. RUM tracking requires RUM to be enabled separately. +The hooks do not initialize, shut down, or change consent for the shared native SDK. + +```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() +); +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. + +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.integration.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts new file mode 100644 index 000000000..ae5d23877 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -0,0 +1,373 @@ +/* + * 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(() => {})) + } +})); + +if (!NativeDdFlags) { + throw new Error('Native flags test double is required'); +} +const native = jest.mocked(NativeDdFlags); +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', () => { + let rum: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + rum = jest + .spyOn(DdRum, 'addFeatureFlagEvaluation') + .mockImplementation(() => new Promise(() => {})); + }); + + afterEach(async () => { + 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() + ); + 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('boolean-flag', 'on'); + }); + + 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('string-flag', 'greeting'); + }); + + 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('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(); + }); + + 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..6294d512e --- /dev/null +++ b/packages/react-native-openfeature/src/tracking.ts @@ -0,0 +1,185 @@ +/* + * 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 { + __ddTrackFlagExposure, + __ddTrackFlagEvaluationEvent, + DdRum +} 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((context, details) => { + const flag = toAssignment(context, details); + if (!flag?.doLog) { + return; + } + __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((context, details) => { + __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(): DatadogTrackingHooks { + return createHook((context, details) => { + if ( + !details.errorCode && + details.reason !== 'ERROR' && + details.variant != null + ) { + DdRum.addFeatureFlagEvaluation( + context.flagKey, + details.variant + ).catch(() => {}); + } + }); +} + +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( + record: ( + context: HookContext, + details: EvaluationDetails + ) => void +): DatadogTrackingHooks { + let active = 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 = true; + }, + // 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) } + : {}) + }; +} From ffe1e4dcf3f014c29fbb3c4223e0fd0ffc57eba1 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 15:12:59 -0700 Subject: [PATCH 2/4] test(openfeature): cover tracking restart and client isolation --- .../__tests__/tracking.integration.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts index ae5d23877..3ce208c86 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -289,6 +289,102 @@ describe('optional native tracking hooks', () => { 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() }; From 89d3e2859462ad58edef55a8f2fb179d07d2750e Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 16:22:28 -0700 Subject: [PATCH 3/4] feat(flags): expose native evaluation endpoint configuration --- .../reactnative/DdFlagsImplementation.kt | 3 ++ .../reactnative/DdFlagsImplementationTest.kt | 50 ++++++++++++++++++- .../ios/Sources/DdFlagsImplementation.swift | 5 ++ packages/core/ios/Tests/DdFlagsTests.swift | 18 ++++++- .../core/src/flags/__tests__/DdFlags.test.ts | 13 +++++ packages/core/src/flags/types.ts | 12 +++++ packages/react-native-openfeature/README.md | 7 +++ 7 files changed, 106 insertions(+), 2 deletions(-) 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 90c1601eb..6be99d6a3 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 @@ -202,6 +202,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/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt index 4bb6e9706..e780a1bec 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 @@ -77,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() @@ -85,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 diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 47e89814c..7d0f8f565 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -182,12 +182,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 ) } diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index d682a8c0c..053b3c9fc 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -393,7 +393,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() @@ -403,6 +404,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() { @@ -414,6 +416,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() { 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/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/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 9e292fe24..84504ff23 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -348,6 +348,13 @@ The exposure and evaluation hooks require the core Datadog SDK to be initialized `DdFlags.enable()` to have completed. RUM tracking 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 { From 8d71eede0e700c6608e72f1764ec348ffc2c5422 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 22 Oct 2026 23:11:36 -0700 Subject: [PATCH 4/4] feat(flags): honor native RUM tracking policy Route independent RUM hooks through the selected native Flags client and preserve telemetry opt-out. Warn once when core tracking helpers are unavailable, with regression coverage and updated usage guidance. Ref: FFL-3355 --- .../reactnative/DdFlagsImplementation.kt | 17 +++++ .../kotlin/com/datadog/reactnative/DdFlags.kt | 12 +++ .../kotlin/com/datadog/reactnative/DdFlags.kt | 19 +++++ .../reactnative/DdFlagsImplementationTest.kt | 31 ++++++++ packages/core/ios/Sources/DdFlags.mm | 15 ++++ .../ios/Sources/DdFlagsImplementation.swift | 10 +++ packages/core/ios/Tests/DdFlagsTests.swift | 25 +++++++ packages/core/src/flags/tracking.ts | 19 +++++ packages/core/src/index.tsx | 3 +- packages/core/src/specs/NativeDdFlags.ts | 8 ++ packages/react-native-openfeature/README.md | 15 +++- .../__tests__/tracking.compatibility.test.ts | 74 +++++++++++++++++++ .../__tests__/tracking.integration.test.ts | 54 +++++++++++--- .../react-native-openfeature/src/tracking.ts | 44 +++++++---- 14 files changed, 318 insertions(+), 28 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts 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 6be99d6a3..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 @@ -166,6 +166,23 @@ class DdFlagsImplementation( 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" } 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 f02678df1..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 @@ -64,6 +64,18 @@ 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, 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 56da9c639..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 @@ -61,6 +61,25 @@ 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, 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 e780a1bec..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 @@ -245,6 +245,37 @@ internal class DdFlagsImplementationTest { } } + @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", diff --git a/packages/core/ios/Sources/DdFlags.mm b/packages/core/ios/Sources/DdFlags.mm index d855eaa8f..20e316b5b 100644 --- a/packages/core/ios/Sources/DdFlags.mm +++ b/packages/core/ios/Sources/DdFlags.mm @@ -67,6 +67,17 @@ @implementation DdFlags [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: @@ -111,4 +122,8 @@ - (void)trackExposure:(NSString *)clientName key:(NSString *)key rawFlag:(NSDict - (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 7d0f8f565..315eaf7ce 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -150,6 +150,16 @@ public class DdFlagsImplementation: NSObject { 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] ?? [:] diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index 053b3c9fc..fca070449 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -219,6 +219,26 @@ class DdFlagsTests: XCTestCase { 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 } @@ -518,6 +538,7 @@ private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { 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 @@ -543,4 +564,8 @@ private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { 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/tracking.ts b/packages/core/src/flags/tracking.ts index 0e6b4c954..7a69d4041 100644 --- a/packages/core/src/flags/tracking.ts +++ b/packages/core/src/flags/tracking.ts @@ -53,6 +53,25 @@ export function __ddTrackFlagEvaluationEvent( ); } +/** @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 { diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index fac48b3f3..13bcdcf14 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -73,7 +73,8 @@ import { TimeProvider } from './utils/time-provider/TimeProvider'; export { __ddTrackFlagExposure, - __ddTrackFlagEvaluationEvent + __ddTrackFlagEvaluationEvent, + __ddTrackFlagRumEvaluation } from './flags/tracking'; export { diff --git a/packages/core/src/specs/NativeDdFlags.ts b/packages/core/src/specs/NativeDdFlags.ts index e9bb54efa..b672f51ec 100644 --- a/packages/core/src/specs/NativeDdFlags.ts +++ b/packages/core/src/specs/NativeDdFlags.ts @@ -45,6 +45,14 @@ export interface Spec extends TurboModule { 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 84504ff23..0d4187eb5 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -345,7 +345,8 @@ Register exposure, evaluation, and RUM hooks independently with the evaluation-o `DatadogOfflineOpenFeatureProvider`, which already track automatically. The exposure and evaluation hooks require the core Datadog SDK to be initialized and -`DdFlags.enable()` to have completed. RUM tracking requires RUM to be enabled separately. +`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 @@ -381,7 +382,7 @@ await OpenFeature.setProviderAndWait('datadog-core', provider, { const tracking = composeDatadogTrackingHooks( createDatadogExposureLoggingHook({ clientName: 'datadog-core' }), createDatadogEvaluationLoggingHook({ clientName: 'datadog-core' }), - createDatadogRumTrackingHook() + createDatadogRumTrackingHook({ clientName: 'datadog-core' }) ); await tracking.initialize(); const client = OpenFeature.getClient('datadog-core'); @@ -405,7 +406,15 @@ Each hook submits its record immediately through an asynchronous native call. Th 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. +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 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 index 3ce208c86..c733aeb92 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -32,7 +32,8 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ setEvaluationContext: jest.fn(), trackEvaluation: jest.fn(), trackExposure: jest.fn(() => new Promise(() => {})), - trackEvaluationEvent: jest.fn(() => new Promise(() => {})) + trackEvaluationEvent: jest.fn(() => new Promise(() => {})), + trackRumEvaluation: jest.fn(() => new Promise(() => {})) } })); @@ -40,6 +41,7 @@ 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( @@ -61,16 +63,15 @@ async function configuredClient( } describe('optional native tracking hooks', () => { - let rum: jest.SpyInstance; - beforeEach(() => { jest.clearAllMocks(); - rum = jest - .spyOn(DdRum, 'addFeatureFlagEvaluation') - .mockImplementation(() => new Promise(() => {})); + jest.spyOn(DdRum, 'addFeatureFlagEvaluation').mockImplementation( + () => new Promise(() => {}) + ); }); afterEach(async () => { + expect(DdRum.addFeatureFlagEvaluation).not.toHaveBeenCalled(); await OpenFeature.clearProviders(); await OpenFeature.clearContext(); jest.restoreAllMocks(); @@ -110,7 +111,7 @@ describe('optional native tracking hooks', () => { const tracking = composeDatadogTrackingHooks( createDatadogExposureLoggingHook({ clientName: 'native-owner' }), createDatadogEvaluationLoggingHook({ clientName: 'native-owner' }), - createDatadogRumTrackingHook() + createDatadogRumTrackingHook({ clientName: 'native-owner' }) ); const { client } = await configuredClient(tracking); jest.useFakeTimers(); @@ -136,7 +137,13 @@ describe('optional native tracking hooks', () => { 'user-1', { country: 'US' } ); - expect(rum).toHaveBeenLastCalledWith('boolean-flag', 'on'); + expect(rum).toHaveBeenLastCalledWith( + 'native-owner', + 'boolean-flag', + 'on', + 'user-1', + { country: 'US' } + ); }); it('does not suppress evaluation or RUM when doLog is false', async () => { @@ -157,7 +164,13 @@ describe('optional native tracking hooks', () => { { country: 'US' }, null ); - expect(rum).toHaveBeenCalledWith('string-flag', 'greeting'); + expect(rum).toHaveBeenCalledWith( + 'default', + 'string-flag', + 'greeting', + 'user-1', + { country: 'US' } + ); }); it('records errors once without an assignment, exposure, or RUM event', async () => { @@ -217,6 +230,29 @@ describe('optional native tracking hooks', () => { 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() diff --git a/packages/react-native-openfeature/src/tracking.ts b/packages/react-native-openfeature/src/tracking.ts index 6294d512e..198aa8011 100644 --- a/packages/react-native-openfeature/src/tracking.ts +++ b/packages/react-native-openfeature/src/tracking.ts @@ -4,11 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -import { - __ddTrackFlagExposure, - __ddTrackFlagEvaluationEvent, - DdRum -} from '@datadog/mobile-react-native'; +import * as DatadogSdk from '@datadog/mobile-react-native'; import type { EvaluationDetails, FlagValue, @@ -40,12 +36,12 @@ export function createDatadogExposureLoggingHook( options: DatadogTrackingHooksOptions = {} ): DatadogTrackingHooks { const clientName = options.clientName ?? 'default'; - return createHook((context, details) => { + return createHook('__ddTrackFlagExposure', (context, details) => { const flag = toAssignment(context, details); if (!flag?.doLog) { return; } - __ddTrackFlagExposure( + DatadogSdk.__ddTrackFlagExposure( clientName, context.flagKey, flag, @@ -59,8 +55,8 @@ export function createDatadogEvaluationLoggingHook( options: DatadogTrackingHooksOptions = {} ): DatadogTrackingHooks { const clientName = options.clientName ?? 'default'; - return createHook((context, details) => { - __ddTrackFlagEvaluationEvent( + return createHook('__ddTrackFlagEvaluationEvent', (context, details) => { + DatadogSdk.__ddTrackFlagEvaluationEvent( clientName, context.flagKey, toAssignment(context, details), @@ -71,17 +67,22 @@ export function createDatadogEvaluationLoggingHook( } /** Adds successful evaluations to the current native RUM view. */ -export function createDatadogRumTrackingHook(): DatadogTrackingHooks { - return createHook((context, details) => { +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 ) { - DdRum.addFeatureFlagEvaluation( + DatadogSdk.__ddTrackFlagRumEvaluation( + clientName, context.flagKey, - details.variant - ).catch(() => {}); + details.variant, + toDdContext(context.context) + ); } }); } @@ -119,12 +120,17 @@ function runTrackingLifecycleOperation( } function createHook( + method: + | '__ddTrackFlagExposure' + | '__ddTrackFlagEvaluationEvent' + | '__ddTrackFlagRumEvaluation', record: ( context: HookContext, details: EvaluationDetails ) => void ): DatadogTrackingHooks { let active = false; + let compatibilityWarningLogged = false; return { hooks: [ { @@ -141,7 +147,15 @@ function createHook( } ], initialize: async () => { - active = true; + 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 () => {