diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 059493dde..906696ba4 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -11,13 +11,26 @@ 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 { + RulesConfigurationResponse, + RulesEngine, + RulesLogger, + RulesValueType +} from './configuration/rules'; import { decodePrecomputedFlags, normalizeWireContext } from './configuration'; import type { ParsedFlagsConfiguration, 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'; /** @@ -54,20 +67,44 @@ 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): Delete this legacy `rulesBased` compatibility shape after a +// flagging-core release contains DataDog/openfeature-js-client#344. Read +// `configuration.rules.response` directly. The configuration is already parsed +// from the complete portable envelope. Do not add raw-service-response handling +// or envelope construction to `FlagsClient`. PR #344 moves parsing to +// `@datadog/flagging-core/configuration`; keep that opt-in import in the local +// wire module and keep `FlagsClient` independent of the parser and Protobuf-ES. +// The released evaluator must also include PR #344's per-flag `PARSE_ERROR` +// results and unknown-field tolerance. +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 @@ -218,7 +255,7 @@ export class FlagsClient { }; /** - * Load a configuration (parsed from a `ConfigurationWire` string via + * Load a configuration (parsed from a complete portable `FlagsConfigurationWire` via * `configurationFromString`) into the client for offline evaluation, then reconcile it * against the active context. * @@ -250,40 +287,63 @@ export class FlagsClient { * reconcile against this stored state without re-decoding, and can never turn an invalid load * into a servable one. * - * FORWARD-COMPAT SEAM: when a rules-based configuration is supported, it must be handled here - * BEFORE the precomputed guard — rules are context-agnostic and must NOT be classified invalid. + * Rules are handled before the precomputed guard. They are context-agnostic and must not be + * classified as invalid when the precomputed branch has a context mismatch. */ private loadConfiguration = ( 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 +364,60 @@ 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') { + // 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(); + } + + 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 +429,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, @@ -366,7 +448,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( @@ -386,69 +468,284 @@ export class FlagsClient { }); }; - private getDetails = ( + private errorDetails = ( + key: string, + defaultValue: T, + errorCode: + | ConfigurationErrorCode + | 'FLAG_NOT_FOUND' + | 'PARSE_ERROR' + | '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' + | 'PARSE_ERROR' + | 'TARGETING_KEY_MISSING' + | 'TYPE_MISMATCH' => { + switch (errorCode) { + case 'INVALID_CONTEXT': + case 'PROVIDER_NOT_READY': + case 'FLAG_NOT_FOUND': + case 'PARSE_ERROR': + case 'TARGETING_KEY_MISSING': + case 'TYPE_MISMATCH': + return errorCode; + default: + return 'GENERAL'; + } + }; + + private getRulesDetails = ( + configuration: RulesConfigurationResponse, + 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 !== 'DISABLED'; + + if (isAssigned) { + // 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, + 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: {} + }, + 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 + ); + } + + 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 details; + 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..b1b198873 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,57 @@ describe('FlagsClient', () => { }) ); + // These builders exercise the temporary `rulesBased` shim from the complete + // portable JSON envelope. They do not model the external UFC service transport. + 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) + } + }) + ); + + // Client tests use this fake to control non-assignment and error results + // independently of flagging-core integration vectors. + 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 +974,350 @@ 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('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(); + 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 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 + ); + }); + + it('returns GENERAL when mismatched precomputed data falls through to invalid rules', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildMixedConfig({ targetingKey: 'user-1' }, {}) + ); + + 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: {} + }), + 'user-1', + { country: 'US' } + ); + }); + + 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', + 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('preserves PARSE_ERROR details and does not track the result', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.defaultValue, + reason: 'ERROR', + variant: 'invalid-variant', + errorCode: 'PARSE_ERROR', + errorMessage: 'Unsupported flag', + metadata: { + allocationKey: 'invalid-allocation', + variationType: 'boolean', + doLog: true + } + })); + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + + expect( + flagsClient.getBooleanDetails('dynamic-flag', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PARSE_ERROR', + errorMessage: 'Unsupported flag' + }); + 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/configuration/rules.ts b/packages/core/src/flags/configuration/rules.ts index 8e5d7a528..d8f1f42c7 100644 --- a/packages/core/src/flags/configuration/rules.ts +++ b/packages/core/src/flags/configuration/rules.ts @@ -17,7 +17,7 @@ import type { EvaluationContext, JsonValue, PrimitiveValue } from '../types'; // release contains DataDog/openfeature-js-client#344. Keep the // `FlagsConfiguration` type import on the flagging-core package root. PR #344 // now preserves invalid flags and reports their stored errors during evaluation. -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 => { 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'