diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index 7c4157747..5d2a1909c 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -188,7 +188,8 @@ actualRN.NativeModules.DdRum = { const DdFlags: DdNativeFlagsType = { enable: jest.fn(() => Promise.resolve()), setEvaluationContext: jest.fn(() => Promise.resolve({})), - trackEvaluation: jest.fn(() => Promise.resolve()) + trackEvaluation: jest.fn(() => Promise.resolve()), + trackEvaluationWithTelemetryTargetingKey: jest.fn(() => Promise.resolve()) }; actualRN.NativeModules.DdFlags = DdFlags; 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..82258435f 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 @@ -119,13 +119,28 @@ class DdFlagsImplementation( targetingKey: String, attributes: ReadableMap, promise: Promise, + ) { + trackEvaluationWithTelemetryTargetingKey( + clientName, key, rawFlag, targetingKey, attributes, targetingKey, promise + ) + } + + @Suppress("LongParameterList") + fun trackEvaluationWithTelemetryTargetingKey( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + telemetryTargetingKey: String?, + promise: Promise, ) { val client = getClient(clientName) val internalClient = _FlagsInternalProxy(client) val flag = convertMapToUnparsedFlag(rawFlag.toMap()) val evaluationContext = buildEvaluationContext(targetingKey, attributes) - internalClient.trackFlagSnapshotEvaluation(key, flag, evaluationContext) + internalClient.trackFlagSnapshotEvaluation(key, flag, evaluationContext, telemetryTargetingKey) promise.resolve(null) } 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..94f1661ce 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,20 @@ class DdFlags( ) { implementation.trackEvaluation(clientName, key, rawFlag, targetingKey, attributes, promise) } + + /** Track with a telemetry identity that preserves omission independently of assignment context. */ + @ReactMethod + override fun trackEvaluationWithTelemetryTargetingKey( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + telemetryTargetingKey: String?, + promise: Promise + ) { + implementation.trackEvaluationWithTelemetryTargetingKey( + clientName, key, rawFlag, targetingKey, attributes, telemetryTargetingKey, 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..d1b01adf7 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,20 @@ class DdFlags(reactContext: ReactApplicationContext) : ReactContextBaseJavaModul ) { implementation.trackEvaluation(clientName, key, rawFlag, targetingKey, attributes, promise) } + + /** Track with a telemetry identity that preserves omission independently of assignment context. */ + @ReactMethod + fun trackEvaluationWithTelemetryTargetingKey( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + telemetryTargetingKey: String?, + promise: Promise + ) { + implementation.trackEvaluationWithTelemetryTargetingKey( + clientName, key, rawFlag, targetingKey, attributes, telemetryTargetingKey, promise + ) + } } diff --git a/packages/core/ios/Sources/DdFlags.mm b/packages/core/ios/Sources/DdFlags.mm index 4cbe7a461..15663563c 100644 --- a/packages/core/ios/Sources/DdFlags.mm +++ b/packages/core/ios/Sources/DdFlags.mm @@ -44,6 +44,18 @@ @implementation DdFlags [self trackEvaluation:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; } +RCT_EXPORT_METHOD(trackEvaluationWithTelemetryTargetingKey:(NSString *)clientName + withKey:(NSString *)key + withRawFlag:(NSDictionary *)rawFlag + targetingKey:(NSString *)targetingKey + attributes:(NSDictionary *)attributes + telemetryTargetingKey:(nullable NSString *)telemetryTargetingKey + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self trackEvaluationWithTelemetryTargetingKey:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes telemetryTargetingKey:telemetryTargetingKey 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 +92,8 @@ - (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)trackEvaluationWithTelemetryTargetingKey:(NSString *)clientName key:(NSString *)key rawFlag:(NSDictionary *)rawFlag targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes telemetryTargetingKey:(nullable NSString *)telemetryTargetingKey resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation trackEvaluationWithTelemetryTargetingKey:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes telemetryTargetingKey:telemetryTargetingKey resolve:resolve reject:reject]; +} @end diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 34020f410..3d99bb237 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -118,6 +118,30 @@ public class DdFlagsImplementation: NSObject { resolve(nil) } + @objc + public func trackEvaluationWithTelemetryTargetingKey(_ clientName: String, key: String, rawFlag: NSDictionary, targetingKey: String, attributes: NSDictionary, telemetryTargetingKey: 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. Make sure the Datadog SDK has been initialized and Flags.enable() has been called.", nil) + return + } + guard let flagAssignment = rawFlag.asFlagAssignment() else { + reject("INVALID_FLAG_ASSIGNMENT", "Failed to parse correct flag assignment from the provided raw flag data.", nil) + return + } + + let evaluationContext = buildEvaluationContext(targetingKey: targetingKey, attributes: attributes) + client.sendFlagEvaluation( + key: key, + assignment: flagAssignment, + context: evaluationContext, + telemetryTargetingKey: telemetryTargetingKey, + observeFullEvaluationData: flagAssignment.observeFullEvaluationData, + flagError: nil + ) + + 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 3fca72ddc..92cb33048 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -154,6 +154,77 @@ class DdFlagsTests: XCTestCase { XCTAssertEqual(mockClient.trackedEvaluation?.assignment.variationKey, "var") } + func testTrackEvaluationWithNullableTelemetryTargetingKey() throws { + for telemetryKey in [nil, "", "telemetry-user", "é", "\u{FFFD}"] as [String?] { + let expectation = self.expectation(description: "Nullable telemetry replay resolves") + let mockClient = MockFlagsClient() + implementation.clientProviders["test_client"] = { mockClient } + let rawFlag: NSDictionary = [ + "allocationKey": "alloc", + "variationKey": "var", + "reason": "TARGETING_MATCH", + "doLog": true, + "value": true + ] + + implementation.trackEvaluationWithTelemetryTargetingKey( + "test_client", + key: "feature_flag", + rawFlag: rawFlag, + targetingKey: "assignment-user", + attributes: ["tier": "pro", "dd_evaluation_telemetry_opt_out": true], + telemetryTargetingKey: telemetryKey, + resolve: { result in + XCTAssertNil(result) + expectation.fulfill() + }, + reject: { code, _, _ in + XCTFail("Should not reject: \(String(describing: code))") + expectation.fulfill() + } + ) + waitForExpectations(timeout: 1.0, handler: nil) + + let replay = try XCTUnwrap(mockClient.trackedTelemetryEvaluation) + XCTAssertEqual(replay.key, "feature_flag") + XCTAssertEqual(replay.assignment.variationKey, "var") + XCTAssertEqual(replay.context.targetingKey, "assignment-user") + XCTAssertEqual(replay.context.attributes["tier"], .string("pro")) + XCTAssertEqual(replay.context.attributes["dd_evaluation_telemetry_opt_out"], .bool(true)) + XCTAssertEqual(replay.telemetryTargetingKey, telemetryKey) + XCTAssertEqual(replay.observeFullEvaluationData, replay.assignment.observeFullEvaluationData) + XCTAssertFalse(replay.observeFullEvaluationData) + XCTAssertNil(replay.flagError) + XCTAssertNil(mockClient.trackedEvaluation) + } + } + + func testTrackEvaluationWithNullableTelemetryTargetingKeyRejectsInvalidFlag() { + let expectation = self.expectation(description: "Nullable telemetry replay rejects invalid flag") + let mockClient = MockFlagsClient() + implementation.clientProviders["test_client"] = { mockClient } + implementation.trackEvaluationWithTelemetryTargetingKey( + "test_client", + key: "feature_flag", + rawFlag: ["allocationKey": "alloc"], + targetingKey: "assignment-user", + attributes: [:], + telemetryTargetingKey: nil, + resolve: { _ in + XCTFail("Should not resolve") + expectation.fulfill() + }, + reject: { code, message, _ in + XCTAssertEqual(code, "INVALID_FLAG_ASSIGNMENT") + XCTAssertNotNil(message) + expectation.fulfill() + } + ) + waitForExpectations(timeout: 1.0, handler: nil) + XCTAssertNil(mockClient.trackedTelemetryEvaluation) + XCTAssertNil(mockClient.trackedEvaluation) + } + func testTrackEvaluationWithInvalidFlag() { let expectation = self.expectation(description: "TrackEvaluation rejects invalid flag") @@ -422,6 +493,7 @@ private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { var lastEvaluationContext: FlagsEvaluationContext? var trackedEvaluation: (key: String, assignment: FlagAssignment, context: FlagsEvaluationContext)? + var trackedTelemetryEvaluation: (key: String, assignment: FlagAssignment, context: FlagsEvaluationContext, telemetryTargetingKey: String?, observeFullEvaluationData: Bool, flagError: String?)? func setEvaluationContext(_ context: DatadogFlags.FlagsEvaluationContext, completion: @escaping (Result) -> Void) { lastEvaluationContext = context @@ -439,4 +511,15 @@ private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { func sendFlagEvaluation(key: String, assignment: DatadogFlags.FlagAssignment, context: DatadogFlags.FlagsEvaluationContext) { trackedEvaluation = (key, assignment, context) } + + func sendFlagEvaluation( + key: String, + assignment: DatadogFlags.FlagAssignment, + context: DatadogFlags.FlagsEvaluationContext, + telemetryTargetingKey: String?, + observeFullEvaluationData: Bool, + flagError: String? + ) { + trackedTelemetryEvaluation = (key, assignment, context, telemetryTargetingKey, observeFullEvaluationData, flagError) + } } diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index c1e56ff57..32cd80f33 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -16,7 +16,7 @@ import type { ParsedFlagsConfiguration, ParsedPrecomputedConfiguration } from './configuration'; -import { processEvaluationContext } from './internal'; +import { processEvaluationContext, telemetryTargetingKey } from './internal'; import type { FlagCacheEntry } from './internal'; import type { JsonValue, EvaluationContext, FlagDetails } from './types'; @@ -77,7 +77,11 @@ interface OfflineFlagsEvaluator { type: 'boolean' | 'string' | 'number' | 'object' ): { details: FlagDetails; - exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; + exposure?: { + flag: FlagCacheEntry; + context: EvaluationContext; + hasTargetingKey?: boolean; + }; }; } @@ -103,6 +107,8 @@ export class FlagsClient { // when set, otherwise the loaded configuration's embedded context. private evaluationContext: EvaluationContext | undefined = undefined; + private evaluationTelemetryTargetingKey: string | null = null; + // The servable flag cache. On the online path it holds the native-fetched flags; on the // offline path it mirrors the decoded snapshot when the configuration is servable. A Map // (not a plain object) so a flag keyed like an inherited property ("toString", @@ -151,9 +157,20 @@ export class FlagsClient { * const flagValue = flagsClient.getBooleanValue('new-feature', false); * ``` */ - setEvaluationContext = async ( - context: EvaluationContext + setEvaluationContext = (context: EvaluationContext): Promise => + this.__ddSetEvaluationContext( + context, + typeof context.targetingKey === 'string' + ); + + /** @internal Preserve subject presence before OpenFeature normalizes its context. */ + __ddSetEvaluationContext = async ( + context: EvaluationContext, + hasTargetingKey: boolean ): Promise => { + const capturedTelemetryKey = telemetryTargetingKey( + hasTargetingKey ? context.targetingKey : null + ); // Make sure to process the incoming context because we don't support nested object values in context. const processedContext = processEvaluationContext(context); @@ -185,8 +202,9 @@ export class FlagsClient { ); this.externalContext = processedContext; - this.externalContextHasTargetingKey = true; + this.externalContextHasTargetingKey = hasTargetingKey; this.evaluationContext = processedContext; + this.evaluationTelemetryTargetingKey = capturedTelemetryKey; this.flagsCache = new Map(Object.entries(result)); // A successful online fetch is authoritative: reset to the clean online state so a @@ -427,6 +445,13 @@ export class FlagsClient { // context (a context-agnostic configuration falls back to an empty context). this.evaluationContext = this.externalContext ?? this.embeddedContext(configuration); + this.evaluationTelemetryTargetingKey = telemetryTargetingKey( + this.externalContext + ? this.externalContextHasTargetingKey + ? this.externalContext.targetingKey + : null + : configuration.context?.targetingKey + ); this.flagsCache = flags; this.configurationStatus = 'ready'; this.configurationError = undefined; @@ -456,17 +481,22 @@ export class FlagsClient { return { status: 'error', errorCode }; }; - private track = (flag: FlagCacheEntry, context: EvaluationContext) => { + private track = ( + flag: FlagCacheEntry, + context: EvaluationContext, + telemetryKey: string | null + ) => { // A non-blocking call; don't await this. A tracking failure, synchronous or not, must // never change the evaluation result. try { this.nativeFlags - .trackEvaluation( + .trackEvaluationWithTelemetryTargetingKey( this.clientName, flag.key, flag, context.targetingKey, - context.attributes ?? {} + context.attributes ?? {}, + telemetryKey ) .catch(this.logTrackingError); } catch (error) { @@ -495,7 +525,15 @@ export class FlagsClient { type ); if (!details.errorCode && exposure) { - this.track(exposure.flag, exposure.context); + this.track( + exposure.flag, + exposure.context, + telemetryTargetingKey( + exposure.hasTargetingKey + ? exposure.context.targetingKey + : null + ) + ); } return details; } @@ -547,7 +585,11 @@ export class FlagsClient { }; } - this.track(flag, this.evaluationContext); + this.track( + flag, + this.evaluationContext, + this.evaluationTelemetryTargetingKey + ); const details: FlagDetails = { key: flag.key, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index e3d423cde..b33c716fd 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -197,25 +197,28 @@ describe('FlagsClient', () => { reason: errorCode ? 'ERROR' : 'TARGETING_MATCH', errorCode }, - exposure: { flag, context } + exposure: { flag, context, hasTargetingKey: true } }) })); expect(client.getBooleanDetails('flag', false).errorCode).toBe( 'TYPE_MISMATCH' ); expect( - NativeModules.DdFlags.trackEvaluation + NativeModules.DdFlags.trackEvaluationWithTelemetryTargetingKey ).not.toHaveBeenCalled(); errorCode = undefined; client.getBooleanDetails('flag', false); - expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeModules.DdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( 'delegate-tracking', 'flag', flag, 'user', { country: 'US' - } + }, + 'user' ); }); }); @@ -337,7 +340,7 @@ describe('FlagsClient', () => { }); jest.spyOn( NativeModules.DdFlags, - 'trackEvaluation' + 'trackEvaluationWithTelemetryTargetingKey' ).mockImplementationOnce(() => { throw new Error('native bridge unavailable'); }); @@ -606,7 +609,7 @@ describe('FlagsClient', () => { }); // No exposure is tracked while serving defaults. expect( - NativeModules.DdFlags.trackEvaluation + NativeModules.DdFlags.trackEvaluationWithTelemetryTargetingKey ).not.toHaveBeenCalled(); }); @@ -764,12 +767,15 @@ describe('FlagsClient', () => { // The exposure must be attributed to B's embedded context (user-2), not A's — a // regression that updated the flags but kept A's context would misattribute here. - expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeModules.DdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( 'default', 'flag-b', expect.any(Object), 'user-2', - {} + {}, + 'user-2' ); }); @@ -937,7 +943,7 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); // Defaults are served, so no exposure is tracked. expect( - NativeModules.DdFlags.trackEvaluation + NativeModules.DdFlags.trackEvaluationWithTelemetryTargetingKey ).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/flags/__tests__/telemetryTargetingKey.test.ts b/packages/core/src/flags/__tests__/telemetryTargetingKey.test.ts new file mode 100644 index 000000000..a674b8fb6 --- /dev/null +++ b/packages/core/src/flags/__tests__/telemetryTargetingKey.test.ts @@ -0,0 +1,152 @@ +/* + * 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 { NativeModules } from 'react-native'; + +import { FlagsClient } from '../FlagsClient'; +import { configurationFromString } from '../configuration'; +import type { EvaluationContext } from '../types'; + +const flag = { + key: 'flag', + value: true, + allocationKey: 'allocation', + variationKey: 'on', + variationType: 'boolean', + variationValue: 'true', + reason: 'STATIC', + doLog: true, + extraLogging: {} +}; +const native = NativeModules.DdFlags; +const replay = () => native.trackEvaluationWithTelemetryTargetingKey.mock.calls; +const wire = (context?: Record) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + context, + response: JSON.stringify({ + data: { + attributes: { + flags: { + flag: { ...flag, variationValue: true } + } + } + } + }) + } + }) + ); + +jest.mock('../../InternalLog', () => ({ InternalLog: { log: jest.fn() } })); + +describe('telemetry targeting key bridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + native.setEvaluationContext.mockResolvedValue({ flag }); + }); + + it.each([ + ['missing', undefined, '', null], + ['null', null, '', null], + ['non-string', 12, '', null], + ['empty', '', '', ''], + ['ordinary', 'subject', 'subject', 'subject'], + ['supplementary', '😀', '😀', '😀'], + ['literal replacement', '\uFFFD', '\uFFFD', '\uFFFD'], + ['decomposed', 'e\u0301', 'e\u0301', 'e\u0301'], + ['lone high', '\uD800', '\uD800', null], + ['lone low', '\uDC00', '\uDC00', null], + ['broken pair', '\uD800x', '\uD800x', null] + ])( + 'preserves assignment context and telemetry identity for %s', + async (_name, input, subject, telemetry) => { + const client = new FlagsClient('test'); + await client.setEvaluationContext({ + targetingKey: input, + attributes: { tier: 'pro' } + } as EvaluationContext); + expect(client.getBooleanValue('flag', false)).toBe(true); + expect(native.setEvaluationContext).toHaveBeenCalledWith( + 'test', + subject, + { tier: 'pro' } + ); + expect(replay()).toEqual([ + ['test', 'flag', flag, subject, { tier: 'pro' }, telemetry] + ]); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + } + ); + + it('retains the accepted key when refresh fails and replaces it on success', async () => { + const client = new FlagsClient('test'); + await client.setEvaluationContext({ targetingKey: '' }); + native.setEvaluationContext.mockRejectedValueOnce(new Error('offline')); + await expect( + client.setEvaluationContext({} as EvaluationContext) + ).rejects.toThrow('offline'); + client.getBooleanValue('flag', false); + await client.setEvaluationContext({} as EvaluationContext); + client.getBooleanValue('flag', false); + expect(replay().map((call: unknown[]) => call[5])).toEqual(['', null]); + }); + + it.each([ + [undefined, null], + [{}, null], + [{ targetingKey: '' }, ''], + [{ targetingKey: 'subject' }, 'subject'] + ])( + 'retains the embedded configuration subject presence: %j', + (context, expected) => { + const client = new FlagsClient('test'); + expect( + client.setConfiguration( + wire(context as Record | undefined) + ) + ).toEqual({ status: 'ready' }); + expect(client.getBooleanValue('flag', false)).toBe(true); + expect(replay()[0][5]).toBe(expected); + } + ); + + it('restores embedded presence after an explicit empty override is cleared', () => { + const client = new FlagsClient('test'); + client.setConfiguration(wire({})); + client.getBooleanValue('flag', false); + client.setEvaluationContextWithoutFetching({ targetingKey: '' }); + client.getBooleanValue('flag', false); + client.resetEvaluationContextWithoutFetching(); + client.getBooleanValue('flag', false); + expect(replay().map((call: unknown[]) => call[5])).toEqual([ + null, + '', + null + ]); + }); + + it.each([false, true])( + 'uses captured offline evaluator presence %s', + hasTargetingKey => { + const client = new FlagsClient('test'); + client.__ddSetOfflineEvaluator(() => ({ + reconcile: () => ({ status: 'ready' }), + evaluate: (key: string, defaultValue: T) => ({ + details: { key, value: defaultValue, reason: 'DEFAULT' }, + exposure: { + flag, + context: { targetingKey: '' }, + hasTargetingKey + } + }) + })); + client.getBooleanValue('flag', false); + expect(replay()[0][5]).toBe(hasTargetingKey ? '' : null); + } + ); +}); diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index 16f44d674..85eb8c201 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -77,3 +77,23 @@ export const processEvaluationContext = ( attributes: Object.fromEntries(attributes) }; }; + +/** Telemetry identity is distinct from the normalized assignment subject. */ +export const telemetryTargetingKey = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return null; + } + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return null; + } + } + return value; +}; diff --git a/packages/core/src/specs/NativeDdFlags.ts b/packages/core/src/specs/NativeDdFlags.ts index 5c8afd010..c513cea90 100644 --- a/packages/core/src/specs/NativeDdFlags.ts +++ b/packages/core/src/specs/NativeDdFlags.ts @@ -29,6 +29,14 @@ export interface Spec extends TurboModule { targetingKey: string, attributes: Object ) => Promise; + readonly trackEvaluationWithTelemetryTargetingKey: ( + clientName: string, + key: string, + rawFlag: Object, + targetingKey: string, + attributes: Object, + telemetryTargetingKey: string | null + ) => Promise; } // eslint-disable-next-line import/no-default-export diff --git a/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts b/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts index be93c4d00..bab231d09 100644 --- a/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts +++ b/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts @@ -11,7 +11,9 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ default: { enable: jest.fn(() => Promise.resolve()), setEvaluationContext: jest.fn(() => Promise.resolve({})), - trackEvaluation: jest.fn(() => Promise.resolve()) + trackEvaluationWithTelemetryTargetingKey: jest.fn(() => + Promise.resolve() + ) } })); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index 34580c5c5..abab73a2a 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -36,7 +36,9 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ default: { enable: jest.fn(() => Promise.resolve()), setEvaluationContext: jest.fn(() => Promise.resolve({})), - trackEvaluation: jest.fn(() => Promise.resolve()) + trackEvaluationWithTelemetryTargetingKey: jest.fn(() => + Promise.resolve() + ) } })); @@ -109,8 +111,12 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect.any(Object) ); } - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(4); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledTimes(4); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( name, 'object-flag', expect.objectContaining({ @@ -120,7 +126,8 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { doLog: false }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); }); @@ -144,7 +151,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { reason: 'SPLIT', flagMetadata: { allocationKey: 'fallback' } }); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( name, 'test-flag', expect.objectContaining({ @@ -156,7 +165,8 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { serialId: '7' }), 'user-1', - { country: 'CA' } + { country: 'CA' }, + 'user-1' ); await OpenFeature.setContext(matchingContext); @@ -166,7 +176,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { variant: 'on', reason: 'TARGETING_MATCH' }); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( name, 'test-flag', expect.objectContaining({ @@ -178,10 +190,13 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { serialId: '7' }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); expect(resolve).toHaveBeenCalledTimes(2); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(2); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledTimes(2); expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); }); @@ -211,7 +226,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { value: true, errorCode: ErrorCode.TARGETING_KEY_MISSING }); - expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).not.toHaveBeenCalled(); await OpenFeature.setContext({ targetingKey: '', country: 'CA' }); expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ @@ -219,13 +236,14 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { reason: 'SPLIT' }); expect( - NativeDdFlags.trackEvaluation + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey ).toHaveBeenLastCalledWith( expect.any(String), 'test-flag', expect.objectContaining({ value: false }), '', - { country: 'CA' } + { country: 'CA' }, + '' ); expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); } @@ -250,13 +268,14 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { reason: 'TARGETING_MATCH' }); expect( - NativeDdFlags.trackEvaluation + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey ).toHaveBeenLastCalledWith( expect.any(String), 'test-flag', expect.objectContaining({ value: true }), '', - { country: 'US' } + { country: 'US' }, + null ); }); @@ -363,13 +382,17 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect(client.getObjectValue('object-flag', {})).toEqual({ enabled: true }); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(3); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledTimes(3); for (const [key, type, value] of [ ['string-flag', 'string', 'hello'], ['number-flag', 'number', '3.14'], ['object-flag', 'object', '{"enabled":true}'] ]) { - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( name, key, expect.objectContaining({ @@ -378,7 +401,8 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { doLog: true }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); } }); @@ -422,12 +446,15 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { const client = OpenFeature.getClient(); expect(client.providerStatus).toBe(ProviderStatus.READY); expect(client.getBooleanValue('boolean-flag', false)).toBe(true); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( expect.any(String), 'boolean-flag', expect.any(Object), 'user-2', - {} + {}, + 'user-2' ); await OpenFeature.setContext(matchingContext); expect(client.providerStatus).toBe(ProviderStatus.ERROR); @@ -454,12 +481,15 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect( OpenFeature.getClient().getBooleanValue('boolean-flag', false) ).toBe(true); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( name, 'boolean-flag', expect.objectContaining({ extraLogging: { source: 'legacy' } }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); }); @@ -484,15 +514,18 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( ErrorCode.FLAG_NOT_FOUND ); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(1); expect( - NativeDdFlags.trackEvaluation + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledTimes(1); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey ).toHaveBeenLastCalledWith( expect.any(String), 'number-flag', expect.objectContaining({ variationType: 'float' }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); }); @@ -511,7 +544,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( ErrorCode.PARSE_ERROR ); - expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).not.toHaveBeenCalled(); }); it('recovers from malformed rules, supports replacement rules, and only emits one configuration event', async () => { @@ -562,7 +597,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { }); expect(client.providerStatus).toBe(ProviderStatus.READY); expect(client.getStringValue('string-flag', 'default')).toBe('hello'); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(1); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledTimes(1); }); it('reports PARSE_ERROR for a rules flag with an unsafe int64 value while the provider stays ready', async () => { @@ -584,7 +621,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { errorCode: ErrorCode.PARSE_ERROR }); expect(client.providerStatus).toBe(ProviderStatus.READY); - expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).not.toHaveBeenCalled(); }); it('reports PARSE_ERROR on the error event when a running provider loads malformed rules', async () => { @@ -620,7 +659,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { }); const client = OpenFeature.getClient(); expect(client.getBooleanValue('test-flag', false)).toBe(true); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( name, 'test-flag', expect.objectContaining({ @@ -630,11 +671,14 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { serialId: '7' }), 'other-user', - { country: 'US' } + { country: 'US' }, + 'other-user' ); await OpenFeature.clearContext(); expect(client.getBooleanValue('test-flag', true)).toBe(false); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( name, 'test-flag', expect.objectContaining({ @@ -642,7 +686,8 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { allocationKey: 'snapshot-allocation' }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); }); @@ -657,7 +702,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { const { provider, name } = setup(configuration); await OpenFeature.setProviderAndWait(provider); OpenFeature.getClient().getBooleanValue('boolean-flag', false); - expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenLastCalledWith( name, 'boolean-flag', expect.objectContaining({ @@ -665,7 +712,8 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { serialId: '42' }), 'user-1', - { country: 'US' } + { country: 'US' }, + 'user-1' ); }); @@ -782,9 +830,9 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { it('logs native tracking failures without changing the evaluated result', async () => { const log = jest.spyOn(InternalLog, 'log'); const { flagsClient } = setup(precomputedConfiguration()); - jest.mocked(NativeDdFlags.trackEvaluation).mockRejectedValueOnce( - new Error('tracking failed') - ); + jest.mocked( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).mockRejectedValueOnce(new Error('tracking failed')); expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); await Promise.resolve(); expect(log).toHaveBeenCalledWith( diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts index 6c5d2d4d2..f8f2f79d5 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -17,13 +17,15 @@ import { ErrorCode, OpenFeature, ProviderStatus } from '@openfeature/web-sdk'; import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; // Stub the native flags TurboModule (TurboModuleRegistry.get returns null under jest), so -// ready-state evaluation can call trackEvaluation without throwing. +// ready-state evaluation can call trackEvaluationWithTelemetryTargetingKey without throwing. jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ __esModule: true, default: { enable: jest.fn(() => Promise.resolve()), setEvaluationContext: jest.fn(() => Promise.resolve({})), - trackEvaluation: jest.fn(() => Promise.resolve()) + trackEvaluationWithTelemetryTargetingKey: jest.fn(() => + Promise.resolve() + ) } })); diff --git a/packages/react-native-openfeature/src/__tests__/provider.test.ts b/packages/react-native-openfeature/src/__tests__/provider.test.ts index b1a9b68fb..74430ee86 100644 --- a/packages/react-native-openfeature/src/__tests__/provider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/provider.test.ts @@ -4,10 +4,12 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { EvaluationContext } from '@openfeature/web-sdk'; + import { DatadogOpenFeatureProvider } from '../provider'; const mockFlagsClient = { - setEvaluationContext: jest.fn(() => Promise.resolve()), + __ddSetEvaluationContext: jest.fn(() => Promise.resolve()), setEvaluationContextWithoutFetching: jest.fn(() => 'ready'), getBooleanDetails: jest.fn(() => ({ key: 'flag', @@ -35,13 +37,14 @@ describe('DatadogOpenFeatureProvider', () => { ); }); - it('fetches on initialize (uses the fetching setEvaluationContext)', async () => { + it('fetches on initialize (uses the fetching __ddSetEvaluationContext)', async () => { const provider = new DatadogOpenFeatureProvider(); await provider.initialize({ targetingKey: 'user-1' }); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith( - expect.objectContaining({ targetingKey: 'user-1' }) + expect(mockFlagsClient.__ddSetEvaluationContext).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-1' }), + true ); expect( mockFlagsClient.setEvaluationContextWithoutFetching @@ -53,14 +56,88 @@ describe('DatadogOpenFeatureProvider', () => { await provider.onContextChange({}, { targetingKey: 'user-2' }); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith( - expect.objectContaining({ targetingKey: 'user-2' }) + expect(mockFlagsClient.__ddSetEvaluationContext).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-2' }), + true + ); + }); + + it.each([ + { context: { country: 'US' }, hasTargetingKey: false }, + { + context: { targetingKey: undefined, country: 'US' }, + hasTargetingKey: false + }, + { + context: { targetingKey: null, country: 'US' }, + hasTargetingKey: false + }, + { + context: { targetingKey: '', country: 'US' }, + hasTargetingKey: true + } + ])( + 'preserves targeting-key presence before normalization on initialize and context change: %j', + async ({ context, hasTargetingKey }) => { + const provider = new DatadogOpenFeatureProvider(); + // Null is untrusted runtime input outside the typed OpenFeature contract. + const input = (context as unknown) as EvaluationContext; + const normalized = { + targetingKey: '', + attributes: { country: 'US' } + }; + + await provider.initialize(input); + expect( + mockFlagsClient.__ddSetEvaluationContext + ).toHaveBeenCalledTimes(1); + expect( + mockFlagsClient.__ddSetEvaluationContext + ).toHaveBeenLastCalledWith(normalized, hasTargetingKey); + + await provider.onContextChange({ targetingKey: 'previous' }, input); + expect( + mockFlagsClient.__ddSetEvaluationContext + ).toHaveBeenCalledTimes(2); + expect( + mockFlagsClient.__ddSetEvaluationContext + ).toHaveBeenLastCalledWith(normalized, hasTargetingKey); + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).not.toHaveBeenCalled(); + } + ); + + it('captures subject presence before a queued context update can be mutated', async () => { + let releaseInitial: () => void = () => undefined; + const initialFetch = new Promise(resolve => { + releaseInitial = resolve; + }); + mockFlagsClient.__ddSetEvaluationContext.mockImplementationOnce( + () => initialFetch + ); + const provider = new DatadogOpenFeatureProvider(); + const initializing = provider.initialize({ targetingKey: 'initial' }); + const nextContext = { targetingKey: 'queued-user', country: 'US' }; + const updating = provider.onContextChange({}, nextContext); + + Reflect.deleteProperty(nextContext, 'targetingKey'); + releaseInitial(); + await initializing; + await updating; + + expect( + mockFlagsClient.__ddSetEvaluationContext + ).toHaveBeenNthCalledWith( + 2, + { targetingKey: 'queued-user', attributes: { country: 'US' } }, + true ); }); it('recovers after a context update fails', async () => { const provider = new DatadogOpenFeatureProvider(); - mockFlagsClient.setEvaluationContext + mockFlagsClient.__ddSetEvaluationContext .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('temporarily offline')) .mockResolvedValueOnce(undefined); @@ -81,10 +158,15 @@ describe('DatadogOpenFeatureProvider', () => { ) ).resolves.toBeUndefined(); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledTimes(3); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenNthCalledWith( + expect(mockFlagsClient.__ddSetEvaluationContext).toHaveBeenCalledTimes( + 3 + ); + expect( + mockFlagsClient.__ddSetEvaluationContext + ).toHaveBeenNthCalledWith( 3, - expect.objectContaining({ targetingKey: 'user-3' }) + expect.objectContaining({ targetingKey: 'user-3' }), + true ); }); diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts index b29651878..516eca89a 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts @@ -10,7 +10,7 @@ import { OpenFeature } from '@openfeature/web-sdk'; import { DatadogOpenFeatureProvider, enrichWithRumUser } from '../index'; const mockFlagsClient = { - setEvaluationContext: jest.fn(() => Promise.resolve()), + __ddSetEvaluationContext: jest.fn(() => Promise.resolve()), getBooleanDetails: jest.fn(() => ({ value: true, reason: 'TARGETING_MATCH', @@ -47,10 +47,13 @@ describe('RUM context core compatibility', () => { plan: 'pro' }); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ - targetingKey: 'explicit-user', - attributes: { plan: 'pro' } - }); + expect(mockFlagsClient.__ddSetEvaluationContext).toHaveBeenCalledWith( + { + targetingKey: 'explicit-user', + attributes: { plan: 'pro' } + }, + true + ); }); it('returns the context unchanged and warns when the helper and InternalLog are missing', () => { @@ -92,10 +95,13 @@ describe('RUM context core compatibility', () => { await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); expect(OpenFeature.getContext()).toStrictEqual(context); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ - targetingKey: 'application-user', - attributes: { plan: 'pro', email: undefined } - }); + expect(mockFlagsClient.__ddSetEvaluationContext).toHaveBeenCalledWith( + { + targetingKey: 'application-user', + attributes: { plan: 'pro', email: undefined } + }, + true + ); expect( OpenFeature.getClient().getBooleanValue('test-flag', false) ).toBe(true); diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index cd181cde3..279d8d42b 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -33,7 +33,9 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ } }) ), - trackEvaluation: jest.fn(() => Promise.resolve()) + trackEvaluationWithTelemetryTargetingKey: jest.fn(() => + Promise.resolve() + ) } })); @@ -124,12 +126,15 @@ describe('explicit RUM context enrichment', () => { 'rum-user', expectedAttributes ); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( clientName, 'test-flag', expect.any(Object), 'rum-user', - expectedAttributes + expectedAttributes, + 'rum-user' ); }); @@ -190,12 +195,15 @@ describe('explicit RUM context enrichment', () => { '', expectedAttributes ); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( clientName, 'test-flag', expect.any(Object), '', - expectedAttributes + expectedAttributes, + null ); } ); @@ -225,12 +233,15 @@ describe('explicit RUM context enrichment', () => { 'rum-user', expectedAttributes ); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( clientName, 'test-flag', expect.any(Object), 'rum-user', - expectedAttributes + expectedAttributes, + 'rum-user' ); }); @@ -272,13 +283,14 @@ describe('explicit RUM context enrichment', () => { } ); expect( - NativeDdFlags.trackEvaluation + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey ).toHaveBeenLastCalledWith( clientName, 'test-flag', expect.any(Object), 'rum-user-b', - { email: 'b@example.com', plan: 'pro', region: 'us' } + { email: 'b@example.com', plan: 'pro', region: 'us' }, + 'rum-user-b' ); expect(applicationContext).toStrictEqual({ region: 'us' }); }); @@ -316,13 +328,14 @@ describe('explicit RUM context enrichment', () => { NativeDdFlags.setEvaluationContext ).toHaveBeenLastCalledWith(clientName, '', { region: 'us' }); expect( - NativeDdFlags.trackEvaluation + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey ).toHaveBeenLastCalledWith( clientName, 'test-flag', expect.any(Object), '', - { region: 'us' } + { region: 'us' }, + null ); }); @@ -403,12 +416,15 @@ describe('explicit RUM context enrichment', () => { 'rum-user', {} ); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + expect( + NativeDdFlags.trackEvaluationWithTelemetryTargetingKey + ).toHaveBeenCalledWith( clientName, 'test-flag', expect.any(Object), 'rum-user', - {} + {}, + 'rum-user' ); }); }); diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index b0eaeb1bf..caa4ab937 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -55,6 +55,8 @@ export class OfflineEvaluation implements OfflineEvaluator { private coreContext: OFEvaluationContext = {}; // Whether the active context selects the precomputed snapshot. Fixed until the next reconcile. private servesPrecomputed = false; + private readonly embeddedContextHasTargetingKey: boolean; + private telemetryHasTargetingKey = false; constructor( configuration: FlagsConfiguration, @@ -62,6 +64,8 @@ export class OfflineEvaluation implements OfflineEvaluator { ) { this.configuration = { ...configuration }; const precomputed = configuration?.precomputed; + this.embeddedContextHasTargetingKey = + typeof precomputed?.context?.targetingKey === 'string'; if ( !precomputed && !this.configuration.rules && @@ -141,6 +145,9 @@ export class OfflineEvaluation implements OfflineEvaluator { // An omitted override adopts the normalized embedded context. Rules-only configurations // must not invent an anonymous subject: missing and explicitly empty keys differ. const embedded = this.configuration.precomputed?.context; + this.telemetryHasTargetingKey = context + ? hasTargetingKey + : this.embeddedContextHasTargetingKey; this.context = context ?? (embedded @@ -188,7 +195,11 @@ export class OfflineEvaluation implements OfflineEvaluator { type: FlagValueType ): { details: FlagDetails; - exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; + exposure?: { + flag: FlagCacheEntry; + context: EvaluationContext; + hasTargetingKey: boolean; + }; } { const context = this.coreContext; const result = this.resolve(key, defaultValue, type, context); @@ -238,7 +249,14 @@ export class OfflineEvaluation implements OfflineEvaluator { ...serial }; // Even doLog=false evaluations reach native code: RUM tracking is independent of exposures. - return { details, exposure: { flag, context: this.context } }; + return { + details, + exposure: { + flag, + context: this.context, + hasTargetingKey: this.telemetryHasTargetingKey + } + }; } private resolve( diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 3d72999f6..47dfc7bf3 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -27,8 +27,9 @@ export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { async initialize(context: OFEvaluationContext = {}): Promise { const ddContext = toDdContext(context); - this.contextChangePromise = this.flagsClient.setEvaluationContext( - ddContext + this.contextChangePromise = this.flagsClient.__ddSetEvaluationContext( + ddContext, + typeof context.targetingKey === 'string' ); await this.contextChangePromise; @@ -39,12 +40,16 @@ export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { newContext: OFEvaluationContext ): Promise { const newDdContext = toDdContext(newContext); + const hasTargetingKey = typeof newContext.targetingKey === 'string'; // Promise chain in case `onContextChange` is called multiple times. this.contextChangePromise = this.contextChangePromise .catch(() => undefined) .then(() => { - return this.flagsClient.setEvaluationContext(newDdContext); + return this.flagsClient.__ddSetEvaluationContext( + newDdContext, + hasTargetingKey + ); }); // Wait for the current context change to complete.