From 5b021fcd29be8bbd81aa3dc3a03632bbd4fc8d9a Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 14:58:10 -0700 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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 () => { From 0891f9b32c864883fc8ee981b365d9a40a5ebb98 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Mon, 2 Nov 2026 12:03:37 -0800 Subject: [PATCH 5/8] chore(android): use released tracking bridge APIs (FFL-3355) --- NATIVE_SDK_VERSIONS.md | 3 ++- benchmarks/android/app/build.gradle | 4 ++-- packages/core/android/build.gradle | 18 +++++++++--------- .../android/build.gradle | 2 +- .../android/build.gradle | 4 ++-- .../react-native-webview/android/build.gradle | 2 +- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/NATIVE_SDK_VERSIONS.md b/NATIVE_SDK_VERSIONS.md index b2f2be2e4..be3420d0b 100644 --- a/NATIVE_SDK_VERSIONS.md +++ b/NATIVE_SDK_VERSIONS.md @@ -1,5 +1,6 @@ | React Native | iOS Bridge / iOS SDK | Android Bridge / Android SDK | |-------------|---------------------|-----------------------------| +| Unreleased | 3.16.1 | 3.16.0 | | 3.11.0 | 3.16.1 | 3.15.0 | | 3.10.0 | 3.16.0 | 3.13.1 | | 3.9.0 | 3.16.0 | 3.13.1 | @@ -84,4 +85,4 @@ | 1.6.0 | 1.19.0 | 1.19.0 | | 1.5.2 | 1.19.0 | 1.18.1 | | 1.5.1 | 1.16.0 | 1.18.1 | -| 1.5.0 | 1.16.0 | 1.18.1 | \ No newline at end of file +| 1.5.0 | 1.16.0 | 1.18.1 | diff --git a/benchmarks/android/app/build.gradle b/benchmarks/android/app/build.gradle index 1a4f94b95..98708153a 100644 --- a/benchmarks/android/app/build.gradle +++ b/benchmarks/android/app/build.gradle @@ -129,7 +129,7 @@ dependencies { // Benchmark tools from dd-sdk-android are used for vitals recording // Remember to bump this alongside the main dd-sdk-android dependencies - implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.15.0") + implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.16.0") // Required for compile-time access to Datadog.getInstance() required by BenchmarkVitalsModuleImpl - implementation("com.datadoghq:dd-sdk-android-core:3.15.0") + implementation("com.datadoghq:dd-sdk-android-core:3.16.0") } diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index c95ee2b16..f6f7ac3ff 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -213,7 +213,7 @@ dependencies { // This breaks builds if the React Native target is below 0.76.0. as it relies on Gradle 8.5.0. // To avoid this, we enforce 1.0.0-beta01 on RN < 0.76.0 if (reactNativeMinorVersion < 76) { - implementation("com.datadoghq:dd-sdk-android-rum:3.15.0") { + implementation("com.datadoghq:dd-sdk-android-rum:3.16.0") { exclude group: "androidx.metrics", module: "metrics-performance" } implementation "androidx.metrics:metrics-performance:1.0.0-beta01" @@ -233,15 +233,15 @@ dependencies { } } } else { - implementation "com.datadoghq:dd-sdk-android-rum:3.15.0" + implementation "com.datadoghq:dd-sdk-android-rum:3.16.0" } - implementation "com.datadoghq:dd-sdk-android-logs:3.15.0" - implementation "com.datadoghq:dd-sdk-android-trace:3.15.0" - implementation "com.datadoghq:dd-sdk-android-webview:3.15.0" - implementation "com.datadoghq:dd-sdk-android-ndk:3.15.0" - implementation "com.datadoghq:dd-sdk-android-flags:3.15.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.15.0" - implementation "com.datadoghq:dd-sdk-android-rum-prelaunch:3.15.0" + implementation "com.datadoghq:dd-sdk-android-logs:3.16.0" + implementation "com.datadoghq:dd-sdk-android-trace:3.16.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.16.0" + implementation "com.datadoghq:dd-sdk-android-ndk:3.16.0" + implementation "com.datadoghq:dd-sdk-android-flags:3.16.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.16.0" + implementation "com.datadoghq:dd-sdk-android-rum-prelaunch:3.16.0" implementation "com.google.code.gson:gson:2.11.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2" diff --git a/packages/internal-testing-tools/android/build.gradle b/packages/internal-testing-tools/android/build.gradle index fe8296905..399da2c33 100644 --- a/packages/internal-testing-tools/android/build.gradle +++ b/packages/internal-testing-tools/android/build.gradle @@ -188,7 +188,7 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native') - implementation "com.datadoghq:dd-sdk-android-core:3.15.0" + implementation "com.datadoghq:dd-sdk-android-core:3.16.0" implementation "com.google.code.gson:gson:2.11.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-session-replay/android/build.gradle b/packages/react-native-session-replay/android/build.gradle index 8c8fee867..79087e5a3 100644 --- a/packages/react-native-session-replay/android/build.gradle +++ b/packages/react-native-session-replay/android/build.gradle @@ -215,8 +215,8 @@ dependencies { api "com.facebook.react:react-android:$reactNativeVersion" } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.datadoghq:dd-sdk-android-session-replay:3.15.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.15.0" + implementation "com.datadoghq:dd-sdk-android-session-replay:3.16.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.16.0" implementation project(path: ':datadog_mobile-react-native') testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-webview/android/build.gradle b/packages/react-native-webview/android/build.gradle index 7790c4335..7ccde2b05 100644 --- a/packages/react-native-webview/android/build.gradle +++ b/packages/react-native-webview/android/build.gradle @@ -196,7 +196,7 @@ dependencies { implementation "com.facebook.react:react-android:$reactNativeVersion" } - implementation "com.datadoghq:dd-sdk-android-webview:3.15.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.16.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native') From 1b684a119f84592ba1c601af530326e99ae51a57 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Mon, 2 Nov 2026 14:24:41 -0800 Subject: [PATCH 6/8] feat(flags): defer independent iOS tracking hooks (FFL-3355) Keep the new tracking hooks Android-only and preserve the existing iOS dependency and installation requirements. Disable optional hooks on iOS with a one-time warning and reject direct independent bridge calls as unsupported. Add platform regression coverage and document the deferred iOS rollout. --- .../ios/Sources/DdFlagsImplementation.swift | 46 ++---- packages/core/ios/Tests/DdFlagsTests.swift | 144 +++--------------- packages/core/src/flags/types.ts | 2 +- packages/react-native-openfeature/README.md | 14 +- .../__tests__/tracking.compatibility.test.ts | 2 + .../__tests__/tracking.integration.test.ts | 51 +++++++ .../react-native-openfeature/src/tracking.ts | 18 ++- 7 files changed, 108 insertions(+), 169 deletions(-) diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 315eaf7ce..191555404 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -94,9 +94,6 @@ 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) } @@ -123,41 +120,22 @@ public class DdFlagsImplementation: NSObject { @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) + rejectUnsupportedTracking(reject) } @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) + rejectUnsupportedTracking(reject) } @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) + rejectUnsupportedTracking(reject) + } + + // The shared TurboModule spec requires these methods, but iOS support is deferred. + private func rejectUnsupportedTracking(_ reject: RCTPromiseReject) { + reject("UNSUPPORTED_PLATFORM", "Independent flag tracking hooks are currently supported only on Android.", nil) } /// Construct an `FlagsEvaluationContext` from a targeting key and a dictionary of attributes. @@ -192,17 +170,12 @@ 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 ) } @@ -260,8 +233,7 @@ extension NSDictionary { variationKey: variationKey, variation: variation, reason: reason, - doLog: doLog, - serialID: (object(forKey: "serialId") as? String).flatMap(Int.init) + doLog: doLog ) } } diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index fca070449..84e47e124 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -174,101 +174,31 @@ 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 } + func testIndependentTrackingRejectsUnsupportedPlatformWithoutUsingClient() { + var clientRequested = false + implementation.clientProviders["tracking"] = { + clientRequested = true + return MockFlagsClient() + } let flag: NSDictionary = [ - "allocationKey": "allocation", "variationKey": "off", "reason": "STATIC", - "doLog": false, "value": false + "allocationKey": "allocation", "variationKey": "on", "reason": "STATIC", + "doLog": true, "value": true ] - 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") + let reject: RCTPromiseReject = { code, message, error in + XCTAssertEqual(code, "UNSUPPORTED_PLATFORM") + XCTAssertEqual(message, "Independent flag tracking hooks are currently supported only on Android.") + XCTAssertNil(error) rejectionCount += 1 } - 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) + let resolve: RCTPromiseResolve = { _ in XCTFail("Unsupported tracking must not report success") } + + implementation.trackExposure("tracking", key: "flag", rawFlag: flag, targetingKey: "user", attributes: [:], resolve: resolve, reject: reject) + implementation.trackEvaluationEvent("tracking", key: "flag", rawFlag: flag, targetingKey: "user", attributes: [:], errorCode: nil, resolve: resolve, reject: reject) + implementation.trackRumEvaluation("tracking", key: "flag", variant: "on", targetingKey: "user", attributes: [:], resolve: resolve, reject: reject) + + XCTAssertEqual(rejectionCount, 3) + XCTAssertFalse(clientRequested) } // MARK: - AnyValue Tests @@ -413,8 +343,7 @@ class DdFlagsTests: XCTestCase { "trackExposures": false, "rumIntegrationEnabled": false, "customFlagsEndpoint": "https://flags.example.com", - "customExposureEndpoint": "https://exposure.example.com", - "customEvaluationEndpoint": "https://evaluation.example.com" + "customExposureEndpoint": "https://exposure.example.com" ] let config = configDict.asFlagsConfiguration() @@ -424,7 +353,6 @@ 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() { @@ -436,20 +364,6 @@ 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() { @@ -507,7 +421,6 @@ 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 @@ -536,9 +449,6 @@ 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 @@ -556,16 +466,4 @@ 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/types.ts b/packages/core/src/flags/types.ts index 265d9a38b..a0065bf93 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -89,7 +89,7 @@ export interface FlagsConfiguration { */ customExposureEndpoint?: string; /** - * Custom server URL for sending aggregated flag evaluation data. + * Android-only custom server URL for sending aggregated flag evaluation data. Ignored on iOS. * * The provided value should only include the base URL, and the endpoint will be appended automatically. * For example, 'https://flags.example.com' becomes 'https://flags.example.com/api/v2/flagevaluation'. diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 0d4187eb5..6f39fc831 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -338,12 +338,18 @@ precomputed-only `configurationFromString` helper. `Error`. Loading a usable configuration after an error emits `Ready`, followed by `ConfigurationChanged`. -### Optional tracking with DatadogCoreProvider +### Optional tracking with DatadogCoreProvider (Android only) Register exposure, evaluation, and RUM hooks independently with the evaluation-only `DatadogCoreProvider`. Do not add these hooks to `DatadogOpenFeatureProvider` or `DatadogOfflineOpenFeatureProvider`, which already track automatically. +Independent tracking hooks are currently supported only on Android, using native SDK +3.16.0. In shared application code, guard this setup with `Platform.OS === 'android'`. +On iOS, `initialize()` warns once per hook and leaves it inactive; flag evaluation and +the existing providers' automatic tracking remain unchanged. No iOS dependency upgrade, +deployment-target change, or CocoaPods override is required for this release. + The exposure and evaluation hooks require the core Datadog SDK to be initialized and `DdFlags.enable()` to have completed. The RUM hook also uses a native Flags client to enforce its evaluation telemetry opt-out policy, and requires RUM to be enabled separately. @@ -416,10 +422,8 @@ Use matching core and OpenFeature package releases containing the tracking APIs. 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. +iOS support for these independent hooks is deferred to a follow-up. The shared native bridge +methods reject direct iOS calls with `UNSUPPORTED_PLATFORM`; they do not report successful tracking. ### Offline initialization diff --git a/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts index dca2bf94e..6fea7eafb 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.compatibility.test.ts @@ -6,6 +6,7 @@ import * as DatadogSdk from '@datadog/mobile-react-native'; import { OpenFeature } from '@openfeature/web-sdk'; +import { Platform } from 'react-native'; import { DatadogCoreProvider } from '../rules-based'; import { @@ -23,6 +24,7 @@ jest.mock('@datadog/mobile-react-native', () => ({ __esModule: true })); describe('tracking hook core compatibility', () => { beforeEach(() => { + jest.replaceProperty(Platform, 'OS', 'android'); jest.spyOn(console, 'warn').mockImplementation(() => {}); }); 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 c733aeb92..21b60ca70 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -6,6 +6,7 @@ import { DdRum } from '@datadog/mobile-react-native'; import { ErrorCode, OpenFeature } from '@openfeature/web-sdk'; +import { Platform } from 'react-native'; import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; import type { DatadogTrackingHook, DatadogTrackingHooks } from '../index'; @@ -65,6 +66,7 @@ async function configuredClient( describe('optional native tracking hooks', () => { beforeEach(() => { jest.clearAllMocks(); + jest.replaceProperty(Platform, 'OS', 'android'); jest.spyOn(DdRum, 'addFeatureFlagEvaluation').mockImplementation( () => new Promise(() => {}) ); @@ -87,6 +89,19 @@ describe('optional native tracking hooks', () => { expect(rum).not.toHaveBeenCalled(); }); + it('does not warn or track for an iOS client without optional hooks', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { client } = await configuredClient(); + + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(console.warn).not.toHaveBeenCalled(); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(native.trackRumEvaluation).not.toHaveBeenCalled(); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + }); + it.each([ ['exposure', createDatadogExposureLoggingHook, 1, 0, 0], ['evaluation', createDatadogEvaluationLoggingHook, 0, 1, 0], @@ -107,6 +122,42 @@ describe('optional native tracking hooks', () => { } ); + describe.each([false, true])('iOS with rules configuration: %s', rules => { + it.each([ + ['exposure', createDatadogExposureLoggingHook], + ['evaluation', createDatadogEvaluationLoggingHook], + ['RUM', createDatadogRumTrackingHook] + ] as const)( + 'disables %s without affecting evaluation', + async (_name, create) => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const tracking = create(); + const { client } = await configuredClient(tracking, rules); + const flagKey = rules ? 'test-flag' : 'boolean-flag'; + + await tracking.initialize(); + for (let i = 0; i < 10; i++) { + expect(client.getBooleanValue(flagKey, false)).toBe(true); + } + await tracking.shutdown(); + await tracking.initialize(); + expect(client.getBooleanValue(flagKey, false)).toBe(true); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('supported only on Android') + ); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(native.trackRumEvaluation).not.toHaveBeenCalled(); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + } + ); + }); + it('submits each record immediately, without JS batching or deduplication', async () => { const tracking = composeDatadogTrackingHooks( createDatadogExposureLoggingHook({ clientName: 'native-owner' }), diff --git a/packages/react-native-openfeature/src/tracking.ts b/packages/react-native-openfeature/src/tracking.ts index 198aa8011..96de2648b 100644 --- a/packages/react-native-openfeature/src/tracking.ts +++ b/packages/react-native-openfeature/src/tracking.ts @@ -11,6 +11,7 @@ import type { Hook, HookContext } from '@openfeature/web-sdk'; +import { Platform } from 'react-native'; import { toDdContext } from './mappers'; @@ -31,7 +32,7 @@ export interface DatadogTrackingHook { shutdown?(): Promise | void; } -/** Logs exposures through native storage, consent handling, and deduplication. */ +/** Android only. Logs exposures through native storage, consent handling, and deduplication. */ export function createDatadogExposureLoggingHook( options: DatadogTrackingHooksOptions = {} ): DatadogTrackingHooks { @@ -50,7 +51,7 @@ export function createDatadogExposureLoggingHook( }); } -/** Logs every evaluation, including errors/defaults, through native aggregation. */ +/** Android only. Logs every evaluation, including errors/defaults, through native aggregation. */ export function createDatadogEvaluationLoggingHook( options: DatadogTrackingHooksOptions = {} ): DatadogTrackingHooks { @@ -66,7 +67,7 @@ export function createDatadogEvaluationLoggingHook( }); } -/** Adds successful evaluations to the current native RUM view. */ +/** Android only. Adds successful evaluations to the current native RUM view. */ export function createDatadogRumTrackingHook( options: DatadogTrackingHooksOptions = {} ): DatadogTrackingHooks { @@ -147,6 +148,17 @@ function createHook( } ], initialize: async () => { + if (Platform.OS !== 'android') { + active = false; + if (!compatibilityWarningLogged) { + compatibilityWarningLogged = true; + // eslint-disable-next-line no-console + console.warn( + 'DATADOG: Independent flag tracking hooks are currently supported only on Android. This hook is disabled; flag evaluation is unaffected.' + ); + } + return; + } active = typeof DatadogSdk[method] === 'function'; if (!active && !compatibilityWarningLogged) { compatibilityWarningLogged = true; From cbe3e1b925c3f1323d8e687438ebb2aceec37ca6 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Mon, 2 Nov 2026 14:40:55 -0800 Subject: [PATCH 7/8] docs(flags): document independent Android tracking bridges (FFL-3355) --- .../reactnative/DdFlagsImplementation.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 b27461602..7d8e05451 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -130,6 +130,15 @@ class DdFlagsImplementation( promise.resolve(null) } + /** + * Track a flag exposure without recording an evaluation event or updating RUM. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param rawFlag The flag assignment from JavaScript. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param promise The promise to resolve after the native call, without waiting for upload. + */ @Suppress("LongParameterList") fun trackExposure( clientName: String, @@ -147,6 +156,16 @@ class DdFlagsImplementation( promise.resolve(null) } + /** + * Track an evaluation event without recording an exposure or updating RUM. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param rawFlag The flag assignment from JavaScript, if available. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param errorCode The evaluation error code, or null for a successful evaluation. + * @param promise The promise to resolve after the native call, without waiting for upload. + */ @Suppress("LongParameterList") fun trackEvaluationEvent( clientName: String, @@ -166,6 +185,15 @@ class DdFlagsImplementation( promise.resolve(null) } + /** + * Associate a flag evaluation with RUM without recording an exposure or evaluation event. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param variant The evaluated flag variant. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param promise The promise to resolve after the native call, without waiting for upload. + */ @Suppress("LongParameterList") fun trackRumEvaluation( clientName: String, From 1ebc9bcbb06626985481baa0311c7a9c022d8a94 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Mon, 2 Nov 2026 18:41:35 -0800 Subject: [PATCH 8/8] fix(flags): preserve tracking assignments and trim payloads (FFL-3355) Preserve successful DEFAULT assignments in independent tracking hooks. Send only tracking metadata to native and avoid serializing flag values. Add rules/precomputed regression tests and sparse Android payload coverage. --- .../reactnative/DdFlagsImplementationTest.kt | 14 +- packages/core/src/flags/tracking.ts | 9 +- .../__tests__/tracking.integration.test.ts | 180 +++++++++++++++++- .../react-native-openfeature/src/tracking.ts | 22 +-- 4 files changed, 199 insertions(+), 26 deletions(-) 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 2c3030ecf..e3fede34c 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt @@ -161,7 +161,12 @@ internal class DdFlagsImplementationTest { DdFlagsImplementation(mock()).trackExposure( "tracking", "flag", - rawFlag(), + mapOf( + "allocationKey" to "allocation", + "variationKey" to "on", + "doLog" to true, + "serialId" to "9007199254740993" + ).toReadableMap(), "user", mapOf("country" to "US").toReadableMap(), mockPromise @@ -191,7 +196,12 @@ internal class DdFlagsImplementationTest { DdFlagsImplementation(mock()).trackEvaluationEvent( "tracking", "flag", - rawFlag(), + mapOf( + "allocationKey" to "allocation", + "variationKey" to "on", + "doLog" to true, + "serialId" to "9007199254740993" + ).toReadableMap(), "user", mapOf("country" to "US").toReadableMap(), null, diff --git a/packages/core/src/flags/tracking.ts b/packages/core/src/flags/tracking.ts index 7a69d4041..dc2eaaebf 100644 --- a/packages/core/src/flags/tracking.ts +++ b/packages/core/src/flags/tracking.ts @@ -13,11 +13,16 @@ import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; import type { EvaluationContext } from './types'; +type FlagTrackingAssignment = Pick< + FlagCacheEntry, + 'allocationKey' | 'variationKey' | 'doLog' | 'serialId' +>; + /** @internal Used by the matching-version OpenFeature tracking hooks. */ export function __ddTrackFlagExposure( clientName: string, key: string, - flag: FlagCacheEntry, + flag: FlagTrackingAssignment, context: EvaluationContext ): void { const captured = processEvaluationContext(context); @@ -36,7 +41,7 @@ export function __ddTrackFlagExposure( export function __ddTrackFlagEvaluationEvent( clientName: string, key: string, - flag: FlagCacheEntry | null, + flag: FlagTrackingAssignment | null, context: EvaluationContext, errorCode: string | null ): void { 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 21b60ca70..a3daebdf5 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -4,13 +4,17 @@ * Copyright 2016-Present Datadog, Inc. */ +import { configurationToString } from '@datadog/flagging-core/rules-based'; import { DdRum } from '@datadog/mobile-react-native'; import { ErrorCode, OpenFeature } from '@openfeature/web-sdk'; import { Platform } from 'react-native'; import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; import type { DatadogTrackingHook, DatadogTrackingHooks } from '../index'; -import { DatadogCoreProvider } from '../rules-based'; +import { + coreConfigurationFromString, + DatadogCoreProvider +} from '../rules-based'; import { composeDatadogTrackingHooks, createDatadogEvaluationLoggingHook, @@ -89,6 +93,174 @@ describe('optional native tracking hooks', () => { expect(rum).not.toHaveBeenCalled(); }); + it.each([ + { rules: false, doLog: false }, + { rules: false, doLog: true }, + { rules: true, doLog: false }, + { rules: true, doLog: true } + ])( + 'preserves assigned DEFAULT results with rules=$rules and doLog=$doLog', + async ({ rules, doLog }) => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ), + rules + ); + const key = rules ? 'test-flag' : 'boolean-flag'; + if (rules) { + const configuration = rulesConfiguration(); + configuration.rules.response.flags[ + key + ].allocations[0].splits[0].reason = 4; + configuration.rules.response.flags[ + key + ].allocations[0].logExposureEvent = doLog; + provider.setConfiguration( + coreConfigurationFromString( + configurationToString(configuration) + ) + ); + } else { + const configuration = precomputedConfiguration(); + configuration.precomputed.response.data.attributes.flags[ + key + ].reason = 'DEFAULT'; + configuration.precomputed.response.data.attributes.flags[ + key + ].doLog = doLog; + provider.setConfiguration(configuration); + } + + expect(client.getBooleanDetails(key, false)).toMatchObject({ + value: true, + reason: 'DEFAULT', + variant: 'on' + }); + expect(native.trackExposure).toHaveBeenCalledTimes(doLog ? 1 : 0); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + key, + expect.objectContaining({ + allocationKey: 'allocation', + variationKey: 'on', + doLog + }), + 'user-1', + { country: 'US' }, + null + ); + expect(rum).toHaveBeenCalledTimes(1); + } + ); + + it('keeps an assignment-free coded default unassigned', async () => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ), + true + ); + const configuration = rulesConfiguration(); + configuration.rules.response.flags['test-flag'].allocations = []; + provider.setConfiguration( + coreConfigurationFromString(configurationToString(configuration)) + ); + + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: false, + reason: 'DEFAULT' + }); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'test-flag', + null, + 'user-1', + { country: 'US' }, + null + ); + }); + + it('treats ERROR without an error code as GENERAL despite assignment metadata', async () => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ) + ); + const configuration = precomputedConfiguration(); + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ].reason = 'ERROR'; + provider.setConfiguration(configuration); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: true, + reason: 'ERROR' + }); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'boolean-flag', + null, + 'user-1', + { country: 'US' }, + 'GENERAL' + ); + }); + + it.each([false, true])( + 'does not serialize unused object values with doLog: %s', + async doLog => { + const { client, provider } = await configuredClient( + composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook() + ) + ); + const configuration = precomputedConfiguration(); + const flag = + configuration.precomputed.response.data.attributes.flags[ + 'object-flag' + ]; + flag.doLog = doLog; + flag.variationValue = { payload: 'x'.repeat(64 * 1024) }; + provider.setConfiguration(configuration); + const stringify = jest.spyOn(JSON, 'stringify'); + const value = client.getObjectValue('object-flag', {}); + expect(value).toEqual(flag.variationValue); + expect( + stringify.mock.calls.filter(([input]) => input === value) + ).toHaveLength(0); + expect(native.trackExposure).toHaveBeenCalledTimes(doLog ? 1 : 0); + expect(native.trackEvaluationEvent).toHaveBeenCalledWith( + 'default', + 'object-flag', + expect.objectContaining({ + allocationKey: 'allocation', + variationKey: 'settings', + doLog + }), + 'user-1', + { country: 'US' }, + null + ); + expect( + native.trackEvaluationEvent.mock.calls[0][2] + ).not.toHaveProperty('value'); + expect( + native.trackEvaluationEvent.mock.calls[0][2] + ).not.toHaveProperty('variationValue'); + } + ); + it('does not warn or track for an iOS client without optional hooks', async () => { jest.replaceProperty(Platform, 'OS', 'ios'); jest.spyOn(console, 'warn').mockImplementation(() => {}); @@ -178,12 +350,9 @@ describe('optional native tracking hooks', () => { 'native-owner', 'boolean-flag', expect.objectContaining({ - value: true, allocationKey: 'allocation', variationKey: 'on', - doLog: true, - variationType: 'boolean', - variationValue: 'true' + doLog: true }), 'user-1', { country: 'US' } @@ -434,7 +603,6 @@ describe('optional native tracking hooks', () => { expect(second.client.getBooleanValue('test-flag', false)).toBe(true); const assignment = expect.objectContaining({ - value: true, allocationKey: 'allocation', variationKey: 'on', doLog: true diff --git a/packages/react-native-openfeature/src/tracking.ts b/packages/react-native-openfeature/src/tracking.ts index 96de2648b..a067fcab1 100644 --- a/packages/react-native-openfeature/src/tracking.ts +++ b/packages/react-native-openfeature/src/tracking.ts @@ -38,7 +38,10 @@ export function createDatadogExposureLoggingHook( ): DatadogTrackingHooks { const clientName = options.clientName ?? 'default'; return createHook('__ddTrackFlagExposure', (context, details) => { - const flag = toAssignment(context, details); + if (details.flagMetadata?.doLog !== true) { + return; + } + const flag = toAssignment(details); if (!flag?.doLog) { return; } @@ -60,7 +63,7 @@ export function createDatadogEvaluationLoggingHook( DatadogSdk.__ddTrackFlagEvaluationEvent( clientName, context.flagKey, - toAssignment(context, details), + toAssignment(details), toDdContext(context.context), details.errorCode ?? (details.reason === 'ERROR' ? 'GENERAL' : null) ); @@ -176,15 +179,11 @@ function createHook( }; } -function toAssignment( - context: HookContext, - details: EvaluationDetails -) { +function toAssignment(details: EvaluationDetails) { const allocationKey = details.flagMetadata?.allocationKey; if ( details.errorCode || details.reason === 'ERROR' || - details.reason === 'DEFAULT' || typeof allocationKey !== 'string' || typeof details.variant !== 'string' ) { @@ -192,18 +191,9 @@ function toAssignment( } 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) } : {})