From 5ae5ddd61ee7f46b5255f5232a9910df290373b7 Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 23 Jul 2026 20:50:05 -0400 Subject: [PATCH 1/3] feat(flags): evaluate dynamic offline rules --- packages/core/src/flags/FlagsClient.ts | 477 ++++++++++++++---- .../src/flags/__tests__/FlagsClient.test.ts | 302 +++++++++++ .../src/flags/configuration/precomputed.ts | 4 +- packages/core/src/flags/types.ts | 1 + 4 files changed, 687 insertions(+), 97 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 059493dde..9299da964 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { UniversalFlagConfigurationV1 } from '@datadog/flagging-core'; + import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; @@ -11,6 +13,18 @@ import type { DdNativeFlagsType } from '../nativeModulesTypes'; // Imported directly (not via the module index): context matching is an internal helper, // used here only to detect a runtime context an offline precomputed configuration cannot honor. import { contextMatchesConfiguration } from './configuration/context'; +import { stringifyFlagValue } from './configuration/precomputed'; +import { + flaggingCoreRulesEngine, + getNoopRulesLogger, + prepareRulesConfiguration, + toRulesEvaluationContext +} from './configuration/rules'; +import type { + RulesEngine, + RulesLogger, + RulesValueType +} from './configuration/rules'; import { decodePrecomputedFlags, normalizeWireContext } from './configuration'; import type { ParsedFlagsConfiguration, @@ -54,20 +68,37 @@ export type ConfigurationResult = * * `'none'` = no offline configuration engaged (the online/fetch path, or nothing loaded yet). */ +type LoadedBranch = + | { status: 'absent' } + | { status: 'invalid'; errorMessage: string } + | { status: 'ready'; value: T }; + +type LoadedPrecomputed = { + configuration: ParsedPrecomputedConfiguration; + flags: Map; +}; + type LoadedConfigurationState = | { kind: 'none' } - | { kind: 'invalid'; errorCode: ConfigurationErrorCode } | { - kind: 'precomputed'; - configuration: ParsedPrecomputedConfiguration; - flags: Map; + kind: 'configuration'; + precomputed: LoadedBranch; + rules: LoadedBranch; }; +// TODO(FFL-2837): Remove this compatibility shape when the published +// FlagsConfiguration type contains the rulesBased branch. +type ConfigurationWithPendingRules = ParsedFlagsConfiguration & { + rulesBased?: { response?: unknown }; +}; + export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') .default; + private readonly rulesEngine: RulesEngine = flaggingCoreRulesEngine; + private clientName: string; // The context the app explicitly set — the online fetch context, or the offline override via @@ -257,33 +288,56 @@ export class FlagsClient { configuration: ParsedFlagsConfiguration ): LoadedConfigurationState => { const precomputed = configuration?.precomputed; + const pendingConfiguration = configuration as ConfigurationWithPendingRules; + const rulesResponse = pendingConfiguration?.rulesBased?.response; - // An empty configuration (a failed/lenient wire parse, or a wire with no precomputed - // branch) is unusable. `configurationFromString` collapses malformed input and - // unsupported versions to the same empty shape, so this is classified as `GENERAL`. - if (!precomputed) { - InternalLog.log( - `No usable precomputed configuration was provided for '${this.clientName}'.`, - SdkVerbosity.WARN - ); - return { kind: 'invalid', errorCode: 'GENERAL' }; - } - - try { - const flags = decodePrecomputedFlags(precomputed.response); - - return { kind: 'precomputed', configuration: precomputed, flags }; - } catch (error) { - // Decoding rejects unsupported payloads (e.g. obfuscated) — an unsupported kind, - // classified as `GENERAL`. - if (error instanceof Error) { + let precomputedBranch: LoadedBranch = { + status: 'absent' + }; + if (precomputed) { + try { + precomputedBranch = { + status: 'ready', + value: { + configuration: precomputed, + flags: decodePrecomputedFlags(precomputed.response) + } + }; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'The precomputed configuration is not valid.'; InternalLog.log( - `Unsupported flags configuration for '${this.clientName}': ${error.message}`, + `Unsupported precomputed configuration for '${this.clientName}': ${errorMessage}`, SdkVerbosity.WARN ); + precomputedBranch = { status: 'invalid', errorMessage }; } - return { kind: 'invalid', errorCode: 'GENERAL' }; } + + let rulesBranch: LoadedBranch = { + status: 'absent' + }; + if (rulesResponse !== undefined) { + const prepared = prepareRulesConfiguration(rulesResponse); + rulesBranch = + prepared.status === 'ready' + ? { + status: 'ready', + value: prepared.configuration + } + : { + status: 'invalid', + errorMessage: prepared.errorMessage + }; + } + + return { + kind: 'configuration', + precomputed: precomputedBranch, + rules: rulesBranch + }; }; /** @@ -304,44 +358,59 @@ export class FlagsClient { ); } - // The load itself failed (malformed/unsupported/undecodable) — independent of context. - if (loaded.kind === 'invalid') { + const { precomputed, rules } = loaded; + + if ( + precomputed.status === 'ready' && + (!this.externalContext || + contextMatchesConfiguration( + precomputed.value.configuration.context, + this.externalContext + )) + ) { + this.evaluationContext = + this.externalContext ?? + this.embeddedContext(precomputed.value.configuration); + this.flagsCache = precomputed.value.flags; + return this.enterReady(); + } + + if (rules.status === 'ready') { + // TODO(FFL-2837): Replace this empty-key fallback after D8 defines + // whether an absent targeting key differs from an empty key. + this.evaluationContext = this.externalContext ?? { + targetingKey: '', + attributes: {} + }; + this.flagsCache = new Map(); + return this.enterReady(); + } + + if (rules.status === 'invalid') { return this.enterError( - loaded.errorCode, - `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` + 'GENERAL', + `The rules configuration for '${this.clientName}' is not usable: ${rules.errorMessage}` ); } - const { configuration, flags } = loaded; + if (precomputed.status === 'invalid') { + return this.enterError( + 'GENERAL', + `The precomputed configuration for '${this.clientName}' is not usable: ${precomputed.errorMessage}` + ); + } - // A precomputed snapshot is bound to the subject it was computed for. If the app set an - // *external* context that does not match, it cannot be served (offline never fetches), so - // it is an error and evaluation serves coded defaults. Only an external override is checked - // here — the configuration's own embedded context (adopted below when no override is set) - // matches by construction, so replacing one snapshot with another for a different subject - // stays `ready`. The decoded snapshot is retained for a later matching context. - if ( - this.externalContext && - !contextMatchesConfiguration( - configuration.context, - this.externalContext - ) - ) { + if (precomputed.status === 'ready') { return this.enterError( 'INVALID_CONTEXT', `The evaluation context does not match the precomputed configuration for '${this.clientName}'. Serving default values. Set a matching context, or use a rules-based configuration for per-context evaluation.` ); } - // Serve against the external override when set, otherwise the configuration's embedded - // context (a context-agnostic configuration falls back to an empty context). - this.evaluationContext = - this.externalContext ?? this.embeddedContext(configuration); - this.flagsCache = flags; - this.configurationStatus = 'ready'; - this.configurationError = undefined; - - return { status: 'ready' }; + return this.enterError( + 'GENERAL', + `The loaded configuration for '${this.clientName}' does not contain a usable branch.` + ); }; /** The evaluation context a precomputed configuration was computed for (empty if agnostic). */ @@ -353,6 +422,12 @@ export class FlagsClient { : { targetingKey: '', attributes: {} }; }; + private enterReady = (): ConfigurationResult => { + this.configurationStatus = 'ready'; + this.configurationError = undefined; + return { status: 'ready' }; + }; + /** Record an error status + message, clear the servable cache, and return the result. */ private enterError = ( errorCode: ConfigurationErrorCode, @@ -386,69 +461,281 @@ export class FlagsClient { }); }; - private getDetails = ( + private errorDetails = ( + key: string, + defaultValue: T, + errorCode: + | ConfigurationErrorCode + | 'FLAG_NOT_FOUND' + | 'TARGETING_KEY_MISSING' + | 'TYPE_MISMATCH', + errorMessage?: string + ): FlagDetails => ({ + key, + value: defaultValue, + reason: 'ERROR', + errorCode, + errorMessage + }); + + private getCachedDetails = ( + flags: Map, + context: EvaluationContext, key: string, defaultValue: T, - type: 'boolean' | 'string' | 'number' | 'object' + type: RulesValueType ): FlagDetails => { - // An offline configuration that cannot be served against the active context surfaces the - // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded - // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. - if (this.configurationStatus === 'error' && this.configurationError) { - return { - key, - value: defaultValue, - reason: 'ERROR', - errorCode: this.configurationError.errorCode, - errorMessage: this.configurationError.errorMessage - }; - } - - if (!this.evaluationContext) { - return { - key, - value: defaultValue, - reason: 'ERROR', - errorCode: 'PROVIDER_NOT_READY', - errorMessage: `The evaluation context is not set for '${this.clientName}'. Please, set context before evaluating any flags.` - }; - } - - // Retrieve the flag from the cache. - const flag = this.flagsCache.get(key); + const flag = flags.get(key); if (!flag) { - return { - key, - value: defaultValue, - reason: 'ERROR', - errorCode: 'FLAG_NOT_FOUND' - }; + return this.errorDetails(key, defaultValue, 'FLAG_NOT_FOUND'); } - // Validate the expected type against the actual flag value type. const actualType = typeof flag.value; if (actualType !== type) { - return { + return this.errorDetails( key, - value: defaultValue, - reason: 'ERROR', - errorCode: 'TYPE_MISMATCH', - errorMessage: `Flag "${key}" returned a value of type "${typeof flag.value}". Use the corresponding method instead of the one expecting "${type}".` - }; + defaultValue, + 'TYPE_MISMATCH', + `Flag "${key}" returned a value of type "${actualType}". Use the corresponding method instead of the one expecting "${type}".` + ); } - this.track(flag, this.evaluationContext); + this.track(flag, context); - const details: FlagDetails = { + return { key: flag.key, value: flag.value as T, variant: flag.variationKey, allocationKey: flag.allocationKey, reason: flag.reason }; + }; + + private normalizeRulesErrorCode = ( + errorCode: string + ): + | ConfigurationErrorCode + | 'FLAG_NOT_FOUND' + | 'TARGETING_KEY_MISSING' + | 'TYPE_MISMATCH' => { + switch (errorCode) { + case 'INVALID_CONTEXT': + case 'PROVIDER_NOT_READY': + case 'FLAG_NOT_FOUND': + case 'TARGETING_KEY_MISSING': + case 'TYPE_MISMATCH': + return errorCode; + default: + return 'GENERAL'; + } + }; + + private getRulesDetails = ( + configuration: UniversalFlagConfigurationV1, + context: EvaluationContext, + logger: RulesLogger, + key: string, + defaultValue: T, + type: RulesValueType + ): FlagDetails => { + const result = this.rulesEngine.evaluate({ + configuration, + type, + flagKey: key, + defaultValue, + context: toRulesEvaluationContext(context), + logger + } as never); + + if (result.errorCode) { + return this.errorDetails( + key, + defaultValue, + this.normalizeRulesErrorCode(result.errorCode), + result.errorMessage + ); + } + + const reason = result.reason ?? 'DEFAULT'; + const isAssigned = + result.variant !== undefined && + result.metadata.allocationKey !== undefined && + reason !== 'DEFAULT' && + reason !== 'DISABLED'; + + if (isAssigned) { + // TODO(FFL-2837): Replace the synthesized native flag after + // flagging-core and the native bridge publish one tracking payload. + this.track( + { + key, + value: result.value, + allocationKey: result.metadata.allocationKey as string, + variationKey: result.variant as string, + variationType: result.metadata.variationType ?? type, + variationValue: stringifyFlagValue(result.value), + reason, + doLog: result.metadata.doLog ?? false, + extraLogging: result.metadata.extraLogging ?? {} + }, + context + ); + } + + return { + key, + value: result.value as T, + variant: result.variant, + allocationKey: result.metadata.allocationKey, + reason + }; + }; + + private getOfflineDetails = ( + loaded: Extract, + context: EvaluationContext, + logger: RulesLogger, + key: string, + defaultValue: T, + type: RulesValueType + ): FlagDetails => { + if ( + loaded.precomputed.status === 'ready' && + contextMatchesConfiguration( + loaded.precomputed.value.configuration.context, + context + ) + ) { + return this.getCachedDetails( + loaded.precomputed.value.flags, + context, + key, + defaultValue, + type + ); + } + + if (loaded.rules.status === 'ready') { + return this.getRulesDetails( + loaded.rules.value, + context, + logger, + key, + defaultValue, + type + ); + } + + if (loaded.rules.status === 'invalid') { + return this.errorDetails( + key, + defaultValue, + 'GENERAL', + loaded.rules.errorMessage + ); + } + + if (loaded.precomputed.status === 'invalid') { + return this.errorDetails( + key, + defaultValue, + 'GENERAL', + loaded.precomputed.errorMessage + ); + } - return details; + if (loaded.precomputed.status === 'ready') { + return this.errorDetails( + key, + defaultValue, + 'INVALID_CONTEXT', + `The evaluation context does not match the precomputed configuration for '${this.clientName}'.` + ); + } + + return this.errorDetails( + key, + defaultValue, + 'GENERAL', + `The loaded configuration for '${this.clientName}' does not contain a usable branch.` + ); + }; + + private getDetails = ( + key: string, + defaultValue: T, + type: RulesValueType, + resolutionContext?: EvaluationContext, + logger: RulesLogger = getNoopRulesLogger() + ): FlagDetails => { + const effectiveContext = + resolutionContext ?? this.externalContext ?? this.evaluationContext; + + if ( + this.loadedConfiguration.kind === 'configuration' && + effectiveContext + ) { + return this.getOfflineDetails( + this.loadedConfiguration, + effectiveContext, + logger, + key, + defaultValue, + type + ); + } + + // An offline configuration that cannot be served against the active context surfaces the + // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded + // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. + if (this.configurationStatus === 'error' && this.configurationError) { + return this.errorDetails( + key, + defaultValue, + this.configurationError.errorCode, + this.configurationError.errorMessage + ); + } + + if (!effectiveContext) { + return this.errorDetails( + key, + defaultValue, + 'PROVIDER_NOT_READY', + `The evaluation context is not set for '${this.clientName}'. Please, set context before evaluating any flags.` + ); + } + + return this.getCachedDetails( + this.flagsCache, + effectiveContext, + key, + defaultValue, + type + ); + }; + + /** + * Evaluate with the effective per-resolution context. + * + * @internal Used by the OpenFeature provider. It is not part of the public + * FlagsClient API. + */ + getDetailsForContext = ( + key: string, + defaultValue: T, + type: RulesValueType, + context: EvaluationContext, + logger: RulesLogger + ): FlagDetails => { + return this.getDetails( + key, + defaultValue, + type, + processEvaluationContext(context), + logger + ); }; /** diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 7df650bf9..ece01d516 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -9,6 +9,16 @@ import { NativeModules } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../config/types/SdkVerbosity'; import { DdFlags } from '../DdFlags'; +import { buildRulesConfiguration } from '../configuration/__tests__/__utils__/rulesTestUtils'; +import { + flaggingCoreRulesEngine, + getNoopRulesLogger +} from '../configuration/rules'; +import type { + RulesEvaluationDetails, + RulesEvaluationRequest, + RulesValueType +} from '../configuration/rules'; import { configurationFromString } from '../configuration'; jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ @@ -371,6 +381,55 @@ describe('FlagsClient', () => { }) ); + const buildRulesConfig = ( + rulesResponse: unknown = buildRulesConfiguration() + ) => + configurationFromString( + JSON.stringify({ + version: 1, + rulesBased: { + response: JSON.stringify(rulesResponse) + } + }) + ); + + const buildMixedConfig = ( + context: Record, + rulesResponse: unknown = buildRulesConfiguration(), + precomputedResponse: unknown = { + data: { + attributes: { + obfuscated: false, + flags: offlineFlags + } + } + } + ) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify(precomputedResponse), + context + }, + rulesBased: { + response: JSON.stringify(rulesResponse) + } + }) + ); + + // TODO(FFL-2837): Remove this fake after flagging-core publishes canonical + // state-matrix vectors for non-assignment and error results. + const installFakeRulesEngine = ( + implementation: ( + request: RulesEvaluationRequest + ) => RulesEvaluationDetails + ) => { + return jest + .spyOn(flaggingCoreRulesEngine, 'evaluate') + .mockImplementation(implementation as never); + }; + describe('setConfiguration', () => { it('serves flags from the configuration without a native fetch', () => { const flagsClient = DdFlags.getClient(); @@ -913,4 +972,247 @@ describe('FlagsClient', () => { ).toEqual({ status: 'error', errorCode: 'PROVIDER_NOT_READY' }); }); }); + + describe('dynamic offline rules', () => { + it('loads rules and evaluates a new context without fetching', () => { + const flagsClient = DdFlags.getClient(); + + expect(flagsClient.setConfiguration(buildRulesConfig())).toEqual({ + status: 'ready' + }); + + expect( + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + + expect( + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'CA' } + }) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + false + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('uses matching precomputed data before rules data', () => { + const evaluate = jest.spyOn(flaggingCoreRulesEngine, 'evaluate'); + const flagsClient = DdFlags.getClient(); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect( + flagsClient.setConfiguration( + buildMixedConfig({ targetingKey: 'user-1' }) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect(evaluate).not.toHaveBeenCalled(); + + evaluate.mockRestore(); + }); + + it('uses rules after a precomputed context mismatch', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildMixedConfig({ targetingKey: 'user-1' }) + ); + + expect( + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + }); + + it('keeps matching precomputed data when rules are invalid', () => { + const invalidRules = buildRulesConfiguration(); + const condition = + invalidRules.flags['dynamic-flag'].allocations[0].rules?.[0] + .conditions[0]; + if (!condition) { + throw new Error('The fixture has no condition.'); + } + (condition as { operator: string }).operator = 'ONE_OF_SHA256'; + + const flagsClient = DdFlags.getClient(); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect( + flagsClient.setConfiguration( + buildMixedConfig({ targetingKey: 'user-1' }, invalidRules) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('returns GENERAL when mismatched precomputed data falls through to invalid rules', () => { + const invalidRules = buildRulesConfiguration(); + const condition = + invalidRules.flags['dynamic-flag'].allocations[0].rules?.[0] + .conditions[0]; + if (!condition) { + throw new Error('The fixture has no condition.'); + } + (condition as { operator: string }).operator = 'ONE_OF_SHA256'; + + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildMixedConfig({ targetingKey: 'user-1' }, invalidRules) + ); + + expect( + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }) + ).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + }); + + it('keeps valid rules when the precomputed branch is invalid', () => { + const flagsClient = DdFlags.getClient(); + + expect( + flagsClient.setConfiguration( + buildMixedConfig( + { targetingKey: 'user-1' }, + buildRulesConfiguration(), + { data: { attributes: {} } } + ) + ) + ).toEqual({ status: 'ready' }); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }); + + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + }); + + it('selects the path again for a per-resolution context', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildMixedConfig({ targetingKey: 'user-1' }) + ); + + const details = flagsClient.getDetailsForContext( + 'dynamic-flag', + false, + 'boolean', + { + targetingKey: 'user-2', + attributes: { country: 'US' } + }, + getNoopRulesLogger() + ); + + expect(details).toMatchObject({ + value: true, + variant: 'enabled', + allocationKey: 'allocation-1' + }); + }); + + it('tracks each real rules assignment even when doLog is false', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + 'default', + 'dynamic-flag', + expect.objectContaining({ + allocationKey: 'allocation-1', + variationKey: 'enabled', + variationType: 'boolean', + variationValue: 'true', + doLog: false, + extraLogging: { experiment: 'checkout' } + }), + 'user-1', + { country: 'US' } + ); + }); + + it('does not track a default rules result', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.defaultValue, + reason: 'DEFAULT', + metadata: {} + })); + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + false + ); + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + + evaluate.mockRestore(); + }); + + it('maps an unknown engine error to GENERAL', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.defaultValue, + reason: 'ERROR', + errorCode: 'FUTURE_ERROR', + metadata: {} + })); + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + + expect( + flagsClient.getBooleanDetails('dynamic-flag', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'GENERAL' + }); + + evaluate.mockRestore(); + }); + }); }); diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index cd2aaea2f..16beb19bb 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -161,7 +161,7 @@ const toFlagCacheEntry = ( allocationKey, variationKey, variationType, - variationValue: stringifyValue(variationValue), + variationValue: stringifyFlagValue(variationValue), reason, doLog, extraLogging: extraLogging ?? {} @@ -204,7 +204,7 @@ const valueMatchesVariationType = ( * Objects/arrays are JSON-encoded; everything else uses `String(...)`, which yields * lowercase `"true"/"false"` for booleans. */ -const stringifyValue = (value: unknown): string => { +export const stringifyFlagValue = (value: unknown): string => { if (value === null) { return 'null'; } diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index ccd0fe04b..5f6eab167 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -168,6 +168,7 @@ export interface EvaluationContext { type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' + | 'TARGETING_KEY_MISSING' | 'PARSE_ERROR' | 'TYPE_MISMATCH' | 'INVALID_CONTEXT' From 821492ed98ddecee314b2bcdcb8466e7103ac04d Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 27 Jul 2026 16:13:18 -0400 Subject: [PATCH 2/3] fix(flags): align dynamic evaluation with upstream --- packages/core/src/flags/FlagsClient.ts | 22 ++++----- .../src/flags/__tests__/FlagsClient.test.ts | 45 +++++++++---------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 9299da964..2b158b70f 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -86,8 +86,8 @@ type LoadedConfigurationState = rules: LoadedBranch; }; -// TODO(FFL-2837): Remove this compatibility shape when the published -// FlagsConfiguration type contains the rulesBased branch. +// TODO(FFL-2837): Delete this legacy rulesBased compatibility shape after a +// flagging-core release contains upstream PR #344 and its rules branch. type ConfigurationWithPendingRules = ParsedFlagsConfiguration & { rulesBased?: { response?: unknown }; }; @@ -376,12 +376,13 @@ export class FlagsClient { } if (rules.status === 'ready') { - // TODO(FFL-2837): Replace this empty-key fallback after D8 defines - // whether an absent targeting key differs from an empty key. - this.evaluationContext = this.externalContext ?? { - targetingKey: '', + // The public SDK context currently requires a targeting key, but the + // rules evaluator distinguishes a missing key from an empty key. + const contextWithoutTargetingKey = { attributes: {} - }; + } as EvaluationContext; + this.evaluationContext = + this.externalContext ?? contextWithoutTargetingKey; this.flagsCache = new Map(); return this.enterReady(); } @@ -565,8 +566,9 @@ export class FlagsClient { reason !== 'DISABLED'; if (isAssigned) { - // TODO(FFL-2837): Replace the synthesized native flag after - // flagging-core and the native bridge publish one tracking payload. + // Rules evaluations use the same native assignment bridge as online + // and precomputed evaluations. The bridge still requires an + // extraLogging object, but the rules response does not provide one. this.track( { key, @@ -577,7 +579,7 @@ export class FlagsClient { variationValue: stringifyFlagValue(result.value), reason, doLog: result.metadata.doLog ?? false, - extraLogging: result.metadata.extraLogging ?? {} + extraLogging: {} }, context ); diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index ece01d516..9f4d0c17c 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -418,8 +418,8 @@ describe('FlagsClient', () => { }) ); - // TODO(FFL-2837): Remove this fake after flagging-core publishes canonical - // state-matrix vectors for non-assignment and error results. + // Client tests use this fake to control non-assignment and error results + // independently of flagging-core integration vectors. const installFakeRulesEngine = ( implementation: ( request: RulesEvaluationRequest @@ -1005,6 +1005,23 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); }); + it('does not replace a missing targeting key with an empty key', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.defaultValue, + reason: 'DEFAULT', + metadata: {} + })); + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(buildRulesConfig()); + flagsClient.getBooleanValue('dynamic-flag', false); + + expect(evaluate.mock.calls[0][0].context).toHaveProperty( + 'targetingKey', + undefined + ); + }); + it('uses matching precomputed data before rules data', () => { const evaluate = jest.spyOn(flaggingCoreRulesEngine, 'evaluate'); const flagsClient = DdFlags.getClient(); @@ -1047,15 +1064,6 @@ describe('FlagsClient', () => { }); it('keeps matching precomputed data when rules are invalid', () => { - const invalidRules = buildRulesConfiguration(); - const condition = - invalidRules.flags['dynamic-flag'].allocations[0].rules?.[0] - .conditions[0]; - if (!condition) { - throw new Error('The fixture has no condition.'); - } - (condition as { operator: string }).operator = 'ONE_OF_SHA256'; - const flagsClient = DdFlags.getClient(); flagsClient.setEvaluationContextWithoutFetching({ targetingKey: 'user-1', @@ -1064,7 +1072,7 @@ describe('FlagsClient', () => { expect( flagsClient.setConfiguration( - buildMixedConfig({ targetingKey: 'user-1' }, invalidRules) + buildMixedConfig({ targetingKey: 'user-1' }, {}) ) ).toEqual({ status: 'ready' }); expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( @@ -1073,18 +1081,9 @@ describe('FlagsClient', () => { }); it('returns GENERAL when mismatched precomputed data falls through to invalid rules', () => { - const invalidRules = buildRulesConfiguration(); - const condition = - invalidRules.flags['dynamic-flag'].allocations[0].rules?.[0] - .conditions[0]; - if (!condition) { - throw new Error('The fixture has no condition.'); - } - (condition as { operator: string }).operator = 'ONE_OF_SHA256'; - const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( - buildMixedConfig({ targetingKey: 'user-1' }, invalidRules) + buildMixedConfig({ targetingKey: 'user-1' }, {}) ); expect( @@ -1164,7 +1163,7 @@ describe('FlagsClient', () => { variationType: 'boolean', variationValue: 'true', doLog: false, - extraLogging: { experiment: 'checkout' } + extraLogging: {} }), 'user-1', { country: 'US' } From a725eb565f27962712d54719928596a56733b6ce Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 28 Jul 2026 15:47:02 -0400 Subject: [PATCH 3/3] fix(flags): align dynamic tracking with upstream --- packages/core/src/flags/FlagsClient.ts | 19 +++-- .../src/flags/__tests__/FlagsClient.test.ts | 75 ++++++++++++++++++- .../core/src/flags/configuration/rules.ts | 2 +- packages/core/src/flags/internal.ts | 4 +- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 2b158b70f..8898591ed 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -4,8 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -import type { UniversalFlagConfigurationV1 } from '@datadog/flagging-core'; - import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; @@ -21,6 +19,7 @@ import { toRulesEvaluationContext } from './configuration/rules'; import type { + RulesConfigurationResponse, RulesEngine, RulesLogger, RulesValueType @@ -31,7 +30,7 @@ import type { ParsedPrecomputedConfiguration } from './configuration'; import { processEvaluationContext } from './internal'; -import type { FlagCacheEntry } from './internal'; +import type { FlagCacheEntry, TrackableAssignment } from './internal'; import type { JsonValue, EvaluationContext, FlagDetails } from './types'; /** @@ -83,11 +82,12 @@ type LoadedConfigurationState = | { kind: 'configuration'; precomputed: LoadedBranch; - rules: LoadedBranch; + rules: LoadedBranch; }; -// TODO(FFL-2837): Delete this legacy rulesBased compatibility shape after a -// flagging-core release contains upstream PR #344 and its rules branch. +// TODO(FFL-2837): Delete this legacy `rulesBased` compatibility shape after a +// flagging-core release contains DataDog/openfeature-js-client#344. Read +// `configuration.rules.response` directly. type ConfigurationWithPendingRules = ParsedFlagsConfiguration & { rulesBased?: { response?: unknown }; }; @@ -316,7 +316,7 @@ export class FlagsClient { } } - let rulesBranch: LoadedBranch = { + let rulesBranch: LoadedBranch = { status: 'absent' }; if (rulesResponse !== undefined) { @@ -442,7 +442,7 @@ export class FlagsClient { return { status: 'error', errorCode }; }; - private track = (flag: FlagCacheEntry, context: EvaluationContext) => { + private track = (flag: TrackableAssignment, context: EvaluationContext) => { // A non-blocking call; don't await this. this.nativeFlags .trackEvaluation( @@ -533,7 +533,7 @@ export class FlagsClient { }; private getRulesDetails = ( - configuration: UniversalFlagConfigurationV1, + configuration: RulesConfigurationResponse, context: EvaluationContext, logger: RulesLogger, key: string, @@ -562,7 +562,6 @@ export class FlagsClient { const isAssigned = result.variant !== undefined && result.metadata.allocationKey !== undefined && - reason !== 'DEFAULT' && reason !== 'DISABLED'; if (isAssigned) { diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 9f4d0c17c..b41042b55 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -1170,7 +1170,80 @@ describe('FlagsClient', () => { ); }); - it('does not track a default rules result', () => { + it.each([ + ['INTEGER', 42], + ['NUMERIC', 1.5] + ] as const)( + 'tracks %s assignments with number metadata', + (variationType, variationValue) => { + const configuration = buildRulesConfiguration(); + const flag = configuration.flags['dynamic-flag']; + flag.variationType = variationType; + flag.variations.enabled.value = variationValue; + flag.variations.disabled.value = 0; + + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig(configuration)); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect(flagsClient.getNumberValue('dynamic-flag', 0)).toBe( + variationValue + ); + expect( + NativeModules.DdFlags.trackEvaluation + ).toHaveBeenCalledWith( + 'default', + 'dynamic-flag', + expect.objectContaining({ + variationType: 'number', + variationValue: String(variationValue) + }), + 'user-1', + { country: 'US' } + ); + } + ); + + it('tracks a DEFAULT result that contains a real assignment', () => { + const evaluate = installFakeRulesEngine(() => ({ + value: true, + reason: 'DEFAULT', + variant: 'default-variant', + metadata: { + allocationKey: 'default-allocation', + variationType: 'boolean', + doLog: false + } + })); + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + 'default', + 'dynamic-flag', + expect.objectContaining({ + allocationKey: 'default-allocation', + variationKey: 'default-variant', + reason: 'DEFAULT' + }), + 'user-1', + {} + ); + + evaluate.mockRestore(); + }); + + it('does not track an unmatched DEFAULT result', () => { const evaluate = installFakeRulesEngine(request => ({ value: request.defaultValue, reason: 'DEFAULT', diff --git a/packages/core/src/flags/configuration/rules.ts b/packages/core/src/flags/configuration/rules.ts index 024f2060b..f18582e6d 100644 --- a/packages/core/src/flags/configuration/rules.ts +++ b/packages/core/src/flags/configuration/rules.ts @@ -15,7 +15,7 @@ import type { EvaluationContext, JsonValue, PrimitiveValue } from '../types'; // TODO(FFL-2837): Replace this legacy UFC v1 alias with // `NonNullable['response']` after a flagging-core // release contains DataDog/openfeature-js-client#344. -type RulesConfigurationResponse = UniversalFlagConfigurationV1; +export type RulesConfigurationResponse = UniversalFlagConfigurationV1; export type RulesValueType = 'boolean' | 'string' | 'number' | 'object'; diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index fc47b2109..da2c2baf6 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -9,7 +9,7 @@ import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { EvaluationContext, PrimitiveValue } from './types'; -export interface FlagCacheEntry { +export interface TrackableAssignment { key: string; value: unknown; allocationKey: string; @@ -21,6 +21,8 @@ export interface FlagCacheEntry { extraLogging: Record; } +export type FlagCacheEntry = TrackableAssignment; + export const processEvaluationContext = ( context: EvaluationContext ): EvaluationContext => {