Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
16 changes: 16 additions & 0 deletions packages/core/ios/Sources/DdFlags.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<facebook::react::TurboModule>)getTurboModule:
Expand Down Expand Up @@ -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
24 changes: 24 additions & 0 deletions packages/core/ios/Sources/DdFlagsImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? [:]
Expand Down
83 changes: 83 additions & 0 deletions packages/core/ios/Tests/DdFlagsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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, DatadogFlags.FlagsError>) -> Void) {
lastEvaluationContext = context
Expand All @@ -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)
}
}
62 changes: 52 additions & 10 deletions packages/core/src/flags/FlagsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,7 +77,11 @@ interface OfflineFlagsEvaluator {
type: 'boolean' | 'string' | 'number' | 'object'
): {
details: FlagDetails<T>;
exposure?: { flag: FlagCacheEntry; context: EvaluationContext };
exposure?: {
flag: FlagCacheEntry;
context: EvaluationContext;
hasTargetingKey?: boolean;
};
};
}

Expand All @@ -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",
Expand Down Expand Up @@ -151,9 +157,20 @@ export class FlagsClient {
* const flagValue = flagsClient.getBooleanValue('new-feature', false);
* ```
*/
setEvaluationContext = async (
context: EvaluationContext
setEvaluationContext = (context: EvaluationContext): Promise<void> =>
this.__ddSetEvaluationContext(
context,
typeof context.targetingKey === 'string'
);

/** @internal Preserve subject presence before OpenFeature normalizes its context. */
__ddSetEvaluationContext = async (
context: EvaluationContext,
hasTargetingKey: boolean
): Promise<void> => {
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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -547,7 +585,11 @@ export class FlagsClient {
};
}

this.track(flag, this.evaluationContext);
this.track(
flag,
this.evaluationContext,
this.evaluationTelemetryTargetingKey
);

const details: FlagDetails<T> = {
key: flag.key,
Expand Down
Loading
Loading