diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 869806ebf..32074bd4b 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -11,24 +11,38 @@ 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, + 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'; /** * Error codes an offline configuration result can carry: * - `INVALID_CONTEXT`: the active context does not match the precomputed snapshot. * - `PROVIDER_NOT_READY`: an offline operation ran with no configuration loaded. - * - `GENERAL`: the loaded configuration is unusable (malformed/unsupported/undecodable). + * - `PARSE_ERROR`: a supplied configuration has no usable capability. + * - `GENERAL`: an unexpected rules-engine error occurred during evaluation. */ export type ConfigurationErrorCode = | 'INVALID_CONTEXT' | 'PROVIDER_NOT_READY' + | 'PARSE_ERROR' | 'GENERAL'; /** @@ -54,13 +68,26 @@ 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; + flagErrors: ReadonlyMap; +}; + +const NO_FLAG_ERRORS: ReadonlyMap = new Map(); + type LoadedConfigurationState = | { kind: 'none' } - | { kind: 'invalid'; errorCode: ConfigurationErrorCode } | { - kind: 'precomputed'; - configuration: ParsedPrecomputedConfiguration; - flags: Map; + kind: 'configuration'; + configurationError?: string; + precomputed: LoadedBranch; + rules: LoadedBranch; }; export class FlagsClient { @@ -68,6 +95,8 @@ export class FlagsClient { 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 @@ -217,13 +246,13 @@ 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. * * For a precomputed configuration this decodes the snapshot once and adopts its embedded * evaluation context when none is set — **no network request is made**. An unusable - * configuration reconciles to an error (`GENERAL`); a context mismatch to `INVALID_CONTEXT`. + * configuration reconciles to an error (`PARSE_ERROR`); a context mismatch to `INVALID_CONTEXT`. * * @param configuration The configuration to load. * @@ -249,40 +278,68 @@ 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; - - // 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' }; - } + const precomputed = configuration.precomputed; - 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), + flagErrors: new Map( + Object.entries(precomputed.flagErrors ?? {}) + ) + } + }; + } 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' }; + } else if (configuration.precomputedError !== undefined) { + precomputedBranch = { + status: 'invalid', + errorMessage: configuration.precomputedError + }; } + + let rulesBranch: LoadedBranch = { + status: 'absent' + }; + if (configuration.rules !== undefined) { + rulesBranch = { + status: 'ready', + value: configuration.rules.response + }; + } else if (configuration.rulesError !== undefined) { + rulesBranch = { + status: 'invalid', + errorMessage: configuration.rulesError + }; + } + + return { + kind: 'configuration', + configurationError: configuration.configurationError, + precomputed: precomputedBranch, + rules: rulesBranch + }; }; /** @@ -303,44 +360,58 @@ export class FlagsClient { ); } - // The load itself failed (malformed/unsupported/undecodable) — independent of context. - if (loaded.kind === 'invalid') { - return this.enterError( - loaded.errorCode, - `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` - ); - } + const { configurationError, precomputed, rules } = loaded; - const { configuration, flags } = loaded; - - // 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 - ) + 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 (configurationError !== undefined) { + return this.enterError('PARSE_ERROR', configurationError); + } + + if (rules.status === 'invalid') { + return this.enterError('PARSE_ERROR', rules.errorMessage); + } + + if (precomputed.status === 'invalid') { + return this.enterError('PARSE_ERROR', precomputed.errorMessage); + } + + 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( + 'PARSE_ERROR', + 'Flags configuration contains no usable capability' + ); }; /** The evaluation context a precomputed configuration was computed for (empty if agnostic). */ @@ -352,6 +423,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, @@ -365,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( @@ -385,69 +462,304 @@ 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, + flagErrors: ReadonlyMap, + 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 { + const flagError = flagErrors.get(key); + if (flagError !== undefined) { + return this.errorDetails( 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.` - }; + defaultValue, + 'PARSE_ERROR', + flagError + ); } - // 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 }; + }; - return details; + 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 '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, + loaded.precomputed.value.flagErrors, + context, + key, + defaultValue, + type + ); + } + + if (loaded.rules.status === 'ready') { + return this.getRulesDetails( + loaded.rules.value, + context, + logger, + key, + defaultValue, + type + ); + } + + if (loaded.configurationError !== undefined) { + return this.errorDetails( + key, + defaultValue, + 'PARSE_ERROR', + loaded.configurationError + ); + } + + if (loaded.rules.status === 'invalid') { + return this.errorDetails( + key, + defaultValue, + 'PARSE_ERROR', + loaded.rules.errorMessage + ); + } + + if (loaded.precomputed.status === 'invalid') { + return this.errorDetails( + key, + defaultValue, + 'PARSE_ERROR', + 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 this.errorDetails( + key, + defaultValue, + 'PARSE_ERROR', + 'Flags configuration contains no usable capability' + ); + }; + + 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 / PARSE_ERROR / 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, + NO_FLAG_ERRORS, + 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 cf00c0d9a..63b995f87 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -9,7 +9,18 @@ import { NativeModules } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../config/types/SdkVerbosity'; import { DdFlags } from '../DdFlags'; +import { RULES_RESPONSE } from '../configuration/__tests__/__utils__/rulesTestUtils'; +import { + flaggingCoreRulesEngine, + getNoopRulesLogger +} from '../configuration/rules'; +import type { + RulesEvaluationDetails, + RulesEvaluationRequest, + RulesValueType +} from '../configuration/rules'; import { configurationFromString } from '../configuration'; +import type { ParsedFlagsConfiguration } from '../configuration'; jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ 'test-boolean-flag': { @@ -377,6 +388,50 @@ describe('FlagsClient', () => { }) ); + const buildRulesConfig = (rulesResponse: unknown = RULES_RESPONSE) => + configurationFromString( + JSON.stringify({ + version: 1, + rules: { response: rulesResponse } + }) + ); + + const buildMixedConfig = ( + context: Record, + rulesResponse: unknown = RULES_RESPONSE, + precomputedResponse: unknown = { + data: { + attributes: { + obfuscated: false, + createdAt: '2026-09-14T00:00:00.000Z', + flags: offlineFlags + } + } + } + ) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify(precomputedResponse), + context + }, + rules: { response: 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(); @@ -449,37 +504,61 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); }); - it('errors with GENERAL for an empty/unparseable configuration', () => { + it('errors with PARSE_ERROR for an unparseable configuration', () => { const flagsClient = DdFlags.getClient(); const result = flagsClient.setConfiguration( configurationFromString('garbage') ); - expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect(result).toEqual({ + status: 'error', + errorCode: 'PARSE_ERROR' + }); expect( flagsClient.getBooleanDetails('offline-bool', false) ).toMatchObject({ value: false, reason: 'ERROR', - errorCode: 'GENERAL' + errorCode: 'PARSE_ERROR', + errorMessage: 'Invalid flags configuration wire format' + }); + }); + + it('distinguishes an empty supplied configuration from no configuration', () => { + const flagsClient = DdFlags.getClient(); + + expect(flagsClient.setConfiguration({})).toEqual({ + status: 'error', + errorCode: 'PARSE_ERROR' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + errorMessage: + 'Flags configuration contains no usable capability' }); }); - it('errors with GENERAL for an unsupported (obfuscated) configuration', () => { + it('errors with PARSE_ERROR for an unsupported (obfuscated) configuration', () => { const flagsClient = DdFlags.getClient(); const result = flagsClient.setConfiguration( buildConfig(offlineFlags, { targetingKey: 'user-1' }, true) ); - expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect(result).toEqual({ + status: 'error', + errorCode: 'PARSE_ERROR' + }); expect( flagsClient.getBooleanDetails('offline-bool', false) - ).toMatchObject({ errorCode: 'GENERAL' }); + ).toMatchObject({ errorCode: 'PARSE_ERROR' }); }); - it('errors with GENERAL for a structurally malformed response envelope', () => { + it('errors with PARSE_ERROR for a structurally malformed response envelope', () => { const flagsClient = DdFlags.getClient(); // A wire whose precomputed response omits `data.attributes.flags` entirely. @@ -495,10 +574,13 @@ describe('FlagsClient', () => { configurationFromString(wire) ); - expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect(result).toEqual({ + status: 'error', + errorCode: 'PARSE_ERROR' + }); expect( flagsClient.getBooleanDetails('offline-bool', false) - ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + ).toMatchObject({ value: false, errorCode: 'PARSE_ERROR' }); }); it('serves a context-agnostic configuration (no embedded context)', () => { @@ -514,12 +596,11 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); }); - it('serves the coded default with TYPE_MISMATCH for a primitive-valued object flag', () => { + it('serves the coded default with PARSE_ERROR for a primitive-valued object flag', () => { const flagsClient = DdFlags.getClient(); - // The decoder accepts any JSON value for an `object` flag, but evaluation requires an - // object. A primitive value (only possible from a malformed/hand-crafted wire, never a - // real Datadog config) is therefore served the coded default with TYPE_MISMATCH. + // Flagging-core 3.0.0 isolates a malformed precomputed flag during + // parsing and retains its flag-scoped error. flagsClient.setConfiguration( buildConfig( { @@ -544,7 +625,7 @@ describe('FlagsClient', () => { ).toMatchObject({ value: { fallback: true }, reason: 'ERROR', - errorCode: 'TYPE_MISMATCH' + errorCode: 'PARSE_ERROR' }); }); @@ -645,7 +726,7 @@ describe('FlagsClient', () => { // Invalid replacement. expect( flagsClient.setConfiguration(configurationFromString('garbage')) - ).toEqual({ status: 'error', errorCode: 'GENERAL' }); + ).toEqual({ status: 'error', errorCode: 'PARSE_ERROR' }); // A later context change must NOT promote the invalid load back to ready. const afterContextChange = flagsClient.setEvaluationContextWithoutFetching( @@ -653,11 +734,11 @@ describe('FlagsClient', () => { ); expect(afterContextChange).toEqual({ status: 'error', - errorCode: 'GENERAL' + errorCode: 'PARSE_ERROR' }); expect( flagsClient.getBooleanDetails('offline-bool', false) - ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + ).toMatchObject({ value: false, errorCode: 'PARSE_ERROR' }); // A valid replacement recovers. expect( @@ -946,4 +1027,530 @@ describe('FlagsClient', () => { ).toEqual({ status: 'error', errorCode: 'PROVIDER_NOT_READY' }); }); }); + + describe('dynamic offline rules', () => { + it('loads rules and evaluates a new context without fetching', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.context.country === 'US', + reason: 'TARGETING_MATCH', + variant: + request.context.country === 'US' ? 'enabled' : 'disabled', + metadata: { + allocationKey: 'allocation', + variationType: 'boolean', + doLog: false + } + })); + 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(); + + evaluate.mockRestore(); + }); + + 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 PARSE_ERROR 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: 'PARSE_ERROR' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'PARSE_ERROR' }); + }); + + it('keeps valid rules when the precomputed branch is invalid', () => { + const flagsClient = DdFlags.getClient(); + + expect( + flagsClient.setConfiguration( + buildMixedConfig( + { targetingKey: 'user-1' }, + RULES_RESPONSE, + { data: { attributes: {} } } + ) + ) + ).toEqual({ status: 'ready' }); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }); + + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + }); + + it('keeps valid rules when precomputedError is present', () => { + const configuration = buildRulesConfig() as ReturnType< + typeof buildRulesConfig + > & { + precomputedError?: string; + }; + configuration.precomputedError = + 'Invalid precomputed configuration wire entry'; + + const flagsClient = DdFlags.getClient(); + + expect(flagsClient.setConfiguration(configuration)).toEqual({ + status: 'ready' + }); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + expect(flagsClient.getBooleanValue('dynamic-flag', false)).toBe( + true + ); + }); + + it('keeps matching precomputed data when rulesError is present', () => { + const configuration = buildConfig(offlineFlags, { + targetingKey: 'user-1' + }) as ReturnType & { + rulesError?: string; + }; + configuration.rulesError = 'Malformed rules data'; + const flagsClient = DdFlags.getClient(); + + expect(flagsClient.setConfiguration(configuration)).toEqual({ + status: 'ready' + }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('returns rulesError when precomputed data does not match', () => { + const configuration = buildConfig(offlineFlags, { + targetingKey: 'user-1' + }) as ReturnType & { + rulesError?: string; + }; + configuration.rulesError = 'Malformed rules data'; + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(configuration); + + expect( + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }) + ).toEqual({ status: 'error', errorCode: 'PARSE_ERROR' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + errorCode: 'PARSE_ERROR', + errorMessage: 'Malformed rules data' + }); + }); + + it('keeps valid rules when configurationError is present', () => { + const configuration = buildRulesConfig() as ReturnType< + typeof buildRulesConfig + > & { + configurationError?: string; + }; + configuration.configurationError = + 'Malformed configuration envelope'; + const flagsClient = DdFlags.getClient(); + + expect(flagsClient.setConfiguration(configuration)).toEqual({ + status: 'ready' + }); + }); + + it('uses configurationError before branch errors when no capability is usable', () => { + const flagsClient = DdFlags.getClient(); + const configuration = ({ + configurationError: 'Malformed configuration envelope', + rulesError: 'Malformed rules data', + precomputedError: 'Malformed precomputed data' + } as unknown) as ParsedFlagsConfiguration; + + expect(flagsClient.setConfiguration(configuration)).toEqual({ + status: 'error', + errorCode: 'PARSE_ERROR' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + errorCode: 'PARSE_ERROR', + errorMessage: 'Malformed configuration envelope' + }); + }); + + it('preserves a matching precomputed flag error before rules fallback', () => { + const configuration = buildMixedConfig({ + targetingKey: 'user-1' + }) as ReturnType & { + precomputed?: { + flagErrors?: Record; + }; + }; + if (!configuration.precomputed) { + throw new Error('The fixture has no precomputed branch.'); + } + configuration.precomputed.flagErrors = { + 'offline-bool': 'Invalid precomputed flag configuration' + }; + const evaluate = jest.spyOn(flaggingCoreRulesEngine, 'evaluate'); + const flagsClient = DdFlags.getClient(); + + expect(flagsClient.setConfiguration(configuration)).toEqual({ + status: 'ready' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PARSE_ERROR', + errorMessage: 'Invalid precomputed flag configuration' + }); + expect(evaluate).not.toHaveBeenCalled(); + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + + evaluate.mockRestore(); + }); + + 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: 'on', + allocationKey: 'allocation' + }); + }); + + 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', + variationKey: 'on', + variationType: 'boolean', + variationValue: 'true', + doLog: false, + extraLogging: {} + }), + 'user-1', + { country: 'US' } + ); + }); + + it.each([42, 1.5])( + 'tracks the number assignment %s with number metadata', + variationValue => { + const evaluate = installFakeRulesEngine(() => ({ + value: variationValue, + reason: 'TARGETING_MATCH', + variant: String(variationValue), + metadata: { + allocationKey: 'allocation', + variationType: 'number', + doLog: false + } + })); + + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + 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' } + ); + + evaluate.mockRestore(); + } + ); + + 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 an upstream unsafe-integer PARSE_ERROR and does not track it', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.defaultValue, + reason: 'ERROR', + variant: 'invalid-variant', + errorCode: 'PARSE_ERROR', + errorMessage: + 'Integer variation value cannot be represented safely as a JavaScript number', + metadata: { + allocationKey: 'invalid-allocation', + variationType: 'number', + doLog: true + } + })); + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + + expect( + flagsClient.getNumberDetails('dynamic-flag', 0) + ).toMatchObject({ + value: 0, + reason: 'ERROR', + errorCode: 'PARSE_ERROR', + errorMessage: + 'Integer variation value cannot be represented safely as a JavaScript number' + }); + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + + evaluate.mockRestore(); + }); + + it('preserves an unsupported-feature-level PARSE_ERROR and does not track it', () => { + const evaluate = installFakeRulesEngine(request => ({ + value: request.defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR', + errorMessage: 'Flag requires an unsupported feature level', + metadata: {} + })); + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration(buildRulesConfig()); + + expect( + flagsClient.getBooleanDetails('dynamic-flag', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PARSE_ERROR', + errorMessage: 'Flag requires an unsupported feature level' + }); + 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/__tests__/__utils__/rulesTestUtils.ts b/packages/core/src/flags/configuration/__tests__/__utils__/rulesTestUtils.ts index e4763ab6b..5448bba51 100644 --- a/packages/core/src/flags/configuration/__tests__/__utils__/rulesTestUtils.ts +++ b/packages/core/src/flags/configuration/__tests__/__utils__/rulesTestUtils.ts @@ -14,9 +14,9 @@ import type { import { configurationFromString } from '../../wire'; // A complete protobuf response from the flagging-core 3.0.0 wire contract. -// It contains one static boolean flag with the key `browser-flag`. +// It contains one static boolean flag with the key `dynamic-flag`. export const RULES_RESPONSE = - 'EgRwcm9kGigKDGJyb3dzZXItZmxhZxIYEAQaAigBIhAKCmFsbG9jYXRpb24iAiADKgJvbg=='; + 'EgRwcm9kGigKDGR5bmFtaWMtZmxhZxIYEAQaAigBIhAKCmFsbG9jYXRpb24iAiADKgJvbg=='; export const buildRulesWire = (response: string = RULES_RESPONSE): string => JSON.stringify({ diff --git a/packages/core/src/flags/configuration/__tests__/rules.test.ts b/packages/core/src/flags/configuration/__tests__/rules.test.ts index b047dd141..db6af228f 100644 --- a/packages/core/src/flags/configuration/__tests__/rules.test.ts +++ b/packages/core/src/flags/configuration/__tests__/rules.test.ts @@ -72,7 +72,7 @@ describe('rules configuration', () => { const result = flaggingCoreRulesEngine.evaluate({ configuration: buildRulesConfiguration(), type: 'boolean', - flagKey: 'browser-flag', + flagKey: 'dynamic-flag', defaultValue: false, context: { targetingKey: 'user-1' }, logger: getNoopRulesLogger() @@ -92,13 +92,13 @@ describe('rules configuration', () => { it('preserves a deterministic flag-scoped PARSE_ERROR', () => { const configuration = buildRulesConfiguration(); - configuration.flags['browser-flag'].minimumFeatureLevel = 1; + configuration.flags['dynamic-flag'].minimumFeatureLevel = 1; expect( flaggingCoreRulesEngine.evaluate({ configuration, type: 'boolean', - flagKey: 'browser-flag', + flagKey: 'dynamic-flag', defaultValue: false, context: { targetingKey: 'user-1' }, logger: getNoopRulesLogger() @@ -144,7 +144,7 @@ describe('rules configuration', () => { fake.evaluate({ configuration: buildRulesConfiguration(), type: 'boolean', - flagKey: 'browser-flag', + flagKey: 'dynamic-flag', defaultValue: false, context: { targetingKey: 'user-1' }, logger: getNoopRulesLogger() diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 6c660fa75..ee90fc014 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -10,7 +10,7 @@ import { configurationFromString, configurationToString } from '../wire'; import { buildRulesWire, RULES_RESPONSE } from './__utils__/rulesTestUtils'; const RULES_RESPONSE_WITH_UNKNOWN_FIELD = - 'EgRwcm9kGigKDGJyb3dzZXItZmxhZxIYEAQaAigBIhAKCmFsbG9jYXRpb24iAiADKgJvbqAGBw=='; + 'EgRwcm9kGigKDGR5bmFtaWMtZmxhZxIYEAQaAigBIhAKCmFsbG9jYXRpb24iAiADKgJvbqAGBw=='; const buildResponse = () => ({ data: { @@ -174,6 +174,6 @@ describe('configurationToString', () => { ); expect(parsed.precomputed).toBeDefined(); - expect(parsed.rules?.response.flags['browser-flag']).toBeDefined(); + expect(parsed.rules?.response.flags['dynamic-flag']).toBeDefined(); }); }); diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 3a989ca68..09cf087df 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -154,7 +154,7 @@ const toFlagCacheEntry = ( allocationKey, variationKey, variationType, - variationValue: stringifyValue(variationValue), + variationValue: stringifyFlagValue(variationValue), reason, doLog, // The native bridge still requires this deprecated field. Portable @@ -199,7 +199,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/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' diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 78c5f096d..839ce8109 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -8,6 +8,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -17,6 +18,7 @@ import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; const READY = { status: 'ready' as const }; const mismatch = { status: 'error' as const, errorCode: 'INVALID_CONTEXT' }; const notReady = { status: 'error' as const, errorCode: 'PROVIDER_NOT_READY' }; +const parseError = { status: 'error' as const, errorCode: 'PARSE_ERROR' }; const generalError = { status: 'error' as const, errorCode: 'GENERAL' }; const mockFlagsClient = { @@ -106,6 +108,15 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); }); + it('rejects initialize with a parse error for an unusable configuration', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( + parseError + ); + + await expect(provider.initialize({})).rejects.toThrow(ParseError); + }); + it('reconciles a matching context change without fetching or signalling a change', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index f0aec033b..f56b6f71f 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -12,6 +12,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -40,6 +41,7 @@ type ProviderErrorEvent = { message: string; errorCode: ErrorCode }; const OF_ERROR_CODE: Record = { INVALID_CONTEXT: ErrorCode.INVALID_CONTEXT, PROVIDER_NOT_READY: ErrorCode.PROVIDER_NOT_READY, + PARSE_ERROR: ErrorCode.PARSE_ERROR, GENERAL: ErrorCode.GENERAL }; @@ -177,6 +179,10 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro return new ProviderNotReadyError( 'The Datadog offline provider has no configuration loaded. Provide one via setConfiguration.' ); + case 'PARSE_ERROR': + return new ParseError( + 'The Datadog offline provider cannot parse the loaded configuration.' + ); default: return new GeneralError( 'The Datadog offline provider cannot serve the loaded configuration.'