diff --git a/libs/providers/flagd/src/lib/service/in-process/in-process-service.ts b/libs/providers/flagd/src/lib/service/in-process/in-process-service.ts index 4dc0de0c2..f1c8101f0 100644 --- a/libs/providers/flagd/src/lib/service/in-process/in-process-service.ts +++ b/libs/providers/flagd/src/lib/service/in-process/in-process-service.ts @@ -9,8 +9,12 @@ export class InProcessService implements Service { private _flagdCore: FlagdCore; private _dataFetcher: DataFetch; - constructor(config: Config, dataFetcher?: DataFetch, logger?: Logger) { - this._flagdCore = new FlagdCore(); + constructor( + config: Config, + dataFetcher?: DataFetch, + private logger?: Logger, + ) { + this._flagdCore = new FlagdCore(undefined, logger); this._dataFetcher = dataFetcher ? dataFetcher : new GrpcFetch(config, undefined, logger); } @@ -63,6 +67,10 @@ export class InProcessService implements Service { } private fill(flags: string): void { - this._flagdCore.setConfigurations(flags); + try { + this._flagdCore.setConfigurations(flags); + } catch (err) { + this.logger?.error(err); + } } } diff --git a/libs/shared/flagd-core/src/lib/flagd-core.ts b/libs/shared/flagd-core/src/lib/flagd-core.ts index 1cce94a92..b83b26d90 100644 --- a/libs/shared/flagd-core/src/lib/flagd-core.ts +++ b/libs/shared/flagd-core/src/lib/flagd-core.ts @@ -5,85 +5,152 @@ import { FlagValue, GeneralError, JsonValue, + FlagValueType, ResolutionDetails, StandardResolutionReasons, TypeMismatchError, + Logger, + SafeLogger, + DefaultLogger, } from '@openfeature/core'; import { Targeting } from './targeting/targeting'; -import { Logger } from '@openfeature/server-sdk'; +import { FeatureFlag } from './feature-flag'; /** * Expose flag configuration setter and flag resolving methods. */ -export class FlagdCore { +export class FlagdCore implements Storage { + private _logger: Logger; private _storage: Storage; - private _targeting = new Targeting(); + private _targeting: Targeting; - /** - * Optionally construct with your own storage layer. - */ - constructor(storage?: Storage) { + constructor(storage?: Storage, logger?: Logger) { this._storage = storage ? storage : new MemoryStorage(); + this._logger = logger ? new SafeLogger(logger) : new DefaultLogger(); + this._targeting = new Targeting(this._logger); } /** - * Add flag configurations to the storage. + * Sets the logger for the FlagdCore instance. + * @param logger - The logger to be set. + * @returns - The FlagdCore instance with the logger set. */ + setLogger(logger: Logger) { + this._logger = new SafeLogger(logger); + return this; + } + setConfigurations(cfg: string): void { this._storage.setConfigurations(cfg); } + getFlag(key: string): FeatureFlag | undefined { + return this._storage.getFlag(key); + } + + getFlags(): Map { + return this._storage.getFlags(); + } + + /** + * Resolve the flag evaluation to a boolean value. + * @param flagKey - The key of the flag to be evaluated. + * @param defaultValue - The default value to be returned if the flag is not found. + * @param evalCtx - The evaluation context to be used for targeting. + * @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger. + * @returns - The resolved value and the reason for the resolution. + */ resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, - evalCtx: EvaluationContext, - logger: Logger, + evalCtx?: EvaluationContext, + logger?: Logger, ): ResolutionDetails { - return this.resolve(flagKey, defaultValue, evalCtx, logger, 'boolean'); + return this.resolve('boolean', flagKey, defaultValue, evalCtx, logger); } + /** + * Resolve the flag evaluation to a string value. + * @param flagKey - The key of the flag to be evaluated. + * @param defaultValue - The default value to be returned if the flag is not found. + * @param evalCtx - The evaluation context to be used for targeting. + * @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger. + * @returns - The resolved value and the reason for the resolution. + */ resolveStringEvaluation( flagKey: string, defaultValue: string, - evalCtx: EvaluationContext, - logger: Logger, + evalCtx?: EvaluationContext, + logger?: Logger, ): ResolutionDetails { - return this.resolve(flagKey, defaultValue, evalCtx, logger, 'string'); + return this.resolve('string', flagKey, defaultValue, evalCtx, logger); } + /** + * Resolve the flag evaluation to a numeric value. + * @param flagKey - The key of the flag to evaluate. + * @param defaultValue - The default value to return if the flag is not found or the evaluation fails. + * @param evalCtx - The evaluation context to be used for targeting. + * @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger. + * @returns - The resolved value and the reason for the resolution. + */ resolveNumberEvaluation( flagKey: string, defaultValue: number, - evalCtx: EvaluationContext, - logger: Logger, + evalCtx?: EvaluationContext, + logger?: Logger, ): ResolutionDetails { - return this.resolve(flagKey, defaultValue, evalCtx, logger, 'number'); + return this.resolve('number', flagKey, defaultValue, evalCtx, logger); } + /** + * Resolve the flag evaluation to an object value. + * @template T - The type of the return value. + * @param flagKey - The key of the flag to resolve. + * @param defaultValue - The default value to use if the flag is not found. + * @param evalCtx - The evaluation context to be used for targeting. + * @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger. + * @returns - The resolved value and the reason for the resolution. + */ resolveObjectEvaluation( flagKey: string, defaultValue: T, - evalCtx: EvaluationContext, - logger: Logger, + evalCtx?: EvaluationContext, + logger?: Logger, ): ResolutionDetails { - return this.resolve(flagKey, defaultValue, evalCtx, logger, 'object'); + return this.resolve('object', flagKey, defaultValue, evalCtx, logger); } - private resolve( + /** + * Resolves the value of a flag based on the specified type type. + * @template T - The type of the flag value. + * @param {FlagValueType} type - The type of the flag value. + * @param {string} flagKey - The key of the flag. + * @param {T} defaultValue - The default value of the flag. + * @param {EvaluationContext} evalCtx - The evaluation context for targeting rules. + * @param {Logger} [logger] - The optional logger for logging errors. + * @returns {ResolutionDetails} - The resolved value and the reason for the resolution. + * @throws {FlagNotFoundError} - If the flag with the given key is not found. + * @throws {TypeMismatchError} - If the evaluated type of the flag does not match the expected type. + * @throws {GeneralError} - If the variant specified in the flag is not found. + */ + resolve( + type: FlagValueType, flagKey: string, defaultValue: T, - evalCtx: EvaluationContext, - logger: Logger, - type: string, + evalCtx: EvaluationContext = {}, + logger?: Logger, ): ResolutionDetails { - // flag exist check + logger ??= this._logger; const flag = this._storage.getFlag(flagKey); + // flag exist check if (!flag) { - throw new FlagNotFoundError(`flag: ${flagKey} not found`); + throw new FlagNotFoundError(`flag: '${flagKey}' not found`); } // flag status check if (flag.state === 'DISABLED') { + logger.debug(`Flag ${flagKey} is disabled, returning default value ${defaultValue}`); return { value: defaultValue, reason: StandardResolutionReasons.DISABLED, @@ -94,6 +161,7 @@ export class FlagdCore { let reason; if (!flag.targeting) { + logger.debug(`Flag ${flagKey} has no targeting rules`); variant = flag.defaultVariant; reason = StandardResolutionReasons.STATIC; } else { diff --git a/libs/shared/flagd-core/src/lib/parser.ts b/libs/shared/flagd-core/src/lib/parser.ts index 268518180..f38a48dd6 100644 --- a/libs/shared/flagd-core/src/lib/parser.ts +++ b/libs/shared/flagd-core/src/lib/parser.ts @@ -1,9 +1,9 @@ import Ajv from 'ajv'; import { FeatureFlag, Flag } from './feature-flag'; -import mydata from '../../flagd-schemas/json/flagd-definitions.json'; +import flagDefinitionSchema from '../../flagd-schemas/json/flagd-definitions.json'; const ajv = new Ajv(); -const matcher = ajv.compile(mydata); +const matcher = ajv.compile(flagDefinitionSchema); const evaluatorKey = '$evaluators'; const bracketReplacer = new RegExp('^[^{]*\\{|}[^}]*$', 'g'); diff --git a/libs/shared/flagd-core/src/lib/storage.ts b/libs/shared/flagd-core/src/lib/storage.ts index 4ac6c47d2..f1eb8a266 100644 --- a/libs/shared/flagd-core/src/lib/storage.ts +++ b/libs/shared/flagd-core/src/lib/storage.ts @@ -5,9 +5,25 @@ import { parse } from './parser'; * The simple contract of the storage layer. */ export interface Storage { + /** + * Sets the configurations from the given string. + * @param cfg The configuration string to be parsed and stored. + * @throws {Error} If the configuration string is invalid. + */ setConfigurations(cfg: string): void; + /** + * Gets the feature flag configuration with the given key. + * @param key The key of the flag to be retrieved. + * @returns The flag with the given key or undefined if not found. + */ getFlag(key: string): FeatureFlag | undefined; + + /** + * Gets all the feature flag configurations. + * @returns The map of all the flags. + */ + getFlags(): Map; } /** @@ -24,11 +40,11 @@ export class MemoryStorage implements Storage { return this._flags.get(key); } + getFlags(): Map { + return this._flags; + } + setConfigurations(cfg: string): void { - try { - this._flags = parse(cfg); - } catch (e) { - console.error(e); - } + this._flags = parse(cfg); } } diff --git a/libs/shared/flagd-core/src/lib/targeting/fractional.ts b/libs/shared/flagd-core/src/lib/targeting/fractional.ts index abe474324..c868831c0 100644 --- a/libs/shared/flagd-core/src/lib/targeting/fractional.ts +++ b/libs/shared/flagd-core/src/lib/targeting/fractional.ts @@ -1,67 +1,70 @@ import { flagdPropertyKey, flagKeyPropertyKey, targetingPropertyKey } from './common'; import MurmurHash3 from 'imurmurhash'; +import type { EvaluationContext, EvaluationContextValue, Logger } from '@openfeature/core'; export const fractionalRule = 'fractional'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function fractional(data: unknown, context: Record): string | null { - if (!Array.isArray(data)) { - return null; - } - - const args = Array.from(data); - if (args.length < 2) { - console.error('Invalid targeting rule. Require at least two buckets.'); - return null; - } - - const flagdProperties = context[flagdPropertyKey]; - if (!flagdProperties) { - return null; - } +export function fractionalFactory(logger: Logger) { + return function fractional(data: unknown, context: EvaluationContext): string | null { + if (!Array.isArray(data)) { + return null; + } - let bucketBy: string; - let buckets: unknown[]; + const args = Array.from(data); + if (args.length < 2) { + logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 2 buckets, got ${args.length}`); + return null; + } - if (typeof args[0] == 'string') { - bucketBy = args[0]; - buckets = args.slice(1, args.length); - } else { - bucketBy = context[targetingPropertyKey]; - if (!bucketBy) { - console.error('Missing targetingKey property'); + const flagdProperties = context[flagdPropertyKey] as { [key: string]: EvaluationContextValue }; + if (!flagdProperties) { + logger.debug('Missing flagd properties, cannot perform fractional targeting'); return null; } - buckets = args; - } + let bucketBy: string | undefined; + let buckets: unknown[]; - let bucketingList; + if (typeof args[0] == 'string') { + bucketBy = args[0]; + buckets = args.slice(1, args.length); + } else { + bucketBy = context[targetingPropertyKey]; + if (!bucketBy) { + logger.debug('Missing targetingKey property, cannot perform fractional targeting'); + return null; + } - try { - bucketingList = toBucketingList(buckets); - } catch (e) { - console.error('Error parsing targeting rule', e); - return null; - } + buckets = args; + } - const hashKey = flagdProperties[flagKeyPropertyKey] + bucketBy; - // hash in signed 32 format. Bitwise operation here works in signed 32 hence the conversion - const hash = new MurmurHash3(hashKey).result() | 0; - const bucket = (Math.abs(hash) / 2147483648) * 100; + let bucketingList; - let sum = 0; - for (let i = 0; i < bucketingList.length; i++) { - const bucketEntry = bucketingList[i]; + try { + bucketingList = toBucketingList(buckets); + } catch (err) { + logger.debug(`Invalid ${fractionalRule} configuration: `, (err as Error).message); + return null; + } + + const hashKey = flagdProperties[flagKeyPropertyKey] + bucketBy; + // hash in signed 32 format. Bitwise operation here works in signed 32 hence the conversion + const hash = new MurmurHash3(hashKey).result() | 0; + const bucket = (Math.abs(hash) / 2147483648) * 100; + + let sum = 0; + for (let i = 0; i < bucketingList.length; i++) { + const bucketEntry = bucketingList[i]; - sum += bucketEntry.fraction; + sum += bucketEntry.fraction; - if (sum >= bucket) { - return bucketEntry.variant; + if (sum >= bucket) { + return bucketEntry.variant; + } } - } - return null; + return null; + }; } function toBucketingList(from: unknown[]): { variant: string; fraction: number }[] { diff --git a/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts b/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts index be52c910c..8b30feb43 100644 --- a/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts +++ b/libs/shared/flagd-core/src/lib/targeting/sem-ver.ts @@ -1,46 +1,52 @@ +import type { Logger } from '@openfeature/core'; import { compare, parse } from 'semver'; export const semVerRule = 'sem_ver'; -export function semVer(data: unknown): boolean { - if (!Array.isArray(data)) { - return false; - } - - const args = Array.from(data); - - if (args.length != 3) { - return false; - } - - const semVer1 = parse(args[0]); - const semVer2 = parse(args[2]); +export function semVerFactory(logger: Logger) { + return function semVer(data: unknown): boolean { + if (!Array.isArray(data)) { + logger.debug(`Invalid ${semVerRule} configuration: Expected an array`); + return false; + } + + const args = Array.from(data); + + if (args.length != 3) { + logger.debug(`Invalid ${semVerRule} configuration: Expected 3 arguments, got ${args.length}`); + return false; + } + + const semVer1 = parse(args[0]); + const semVer2 = parse(args[2]); + + if (!semVer1 || !semVer2) { + logger.debug(`Invalid ${semVerRule} configuration: Unable to parse semver`); + return false; + } + + const operator = String(args[1]); + const result = compare(semVer1, semVer2); + + switch (operator) { + case '=': + return result == 0; + case '!=': + return result != 0; + case '<': + return result < 0; + case '<=': + return result <= 0; + case '>=': + return result >= 0; + case '>': + return result > 0; + case '^': + return semVer1.major == semVer2.major; + case '~': + return semVer1.major == semVer2.major && semVer1.minor == semVer2.minor; + } - if (!semVer1 || !semVer2) { return false; - } - - const operator = String(args[1]); - const result = compare(semVer1, semVer2); - - switch (operator) { - case '=': - return result == 0; - case '!=': - return result != 0; - case '<': - return result < 0; - case '<=': - return result <= 0; - case '>=': - return result >= 0; - case '>': - return result > 0; - case '^': - return semVer1.major == semVer2.major; - case '~': - return semVer1.major == semVer2.major && semVer1.minor == semVer2.minor; - } - - return false; + }; } diff --git a/libs/shared/flagd-core/src/lib/targeting/string-comp.ts b/libs/shared/flagd-core/src/lib/targeting/string-comp.ts index afdc66052..3303ae52a 100644 --- a/libs/shared/flagd-core/src/lib/targeting/string-comp.ts +++ b/libs/shared/flagd-core/src/lib/targeting/string-comp.ts @@ -1,33 +1,46 @@ +import { type Logger } from '@openfeature/core'; + export const startsWithRule = 'starts_with'; export const endsWithRule = 'ends_with'; -export function startsWithHandler(data: unknown) { - return compare(startsWithRule, data); -} - -export function endsWithHandler(data: unknown) { - return compare(endsWithRule, data); -} - -function compare(method: string, data: unknown): boolean { - if (!Array.isArray(data)) { - return false; +export function stringCompareFactory(logger: Logger) { + function startsWithHandler(data: unknown) { + return compare(startsWithRule, data); } - if (data.length != 2) { - return false; + function endsWithHandler(data: unknown) { + return compare(endsWithRule, data); } - if (typeof data[0] !== 'string' || typeof data[1] !== 'string') { - return false; - } + function compare(method: string, data: unknown): boolean { + if (!Array.isArray(data)) { + logger.debug('Invalid comparison configuration: input is not an array'); + return false; + } + + if (data.length != 2) { + logger.debug(`Invalid comparison configuration: invalid array length ${data.length}`); + return false; + } - switch (method) { - case startsWithRule: - return data[0].startsWith(data[1]); - case endsWithRule: - return data[0].endsWith(data[1]); - default: + if (typeof data[0] !== 'string' || typeof data[1] !== 'string') { + logger.debug('Invalid comparison configuration: array values are not strings'); return false; + } + + switch (method) { + case startsWithRule: + return data[0].startsWith(data[1]); + case endsWithRule: + return data[0].endsWith(data[1]); + default: + logger.debug(`Invalid comparison configuration: Invalid method '${method}'`); + return false; + } } + + return { + startsWithHandler, + endsWithHandler, + }; } diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts index e593c0054..b161ce884 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts @@ -1,10 +1,18 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ import { Targeting } from './targeting'; +const logger = { + debug: () => {}, + error: () => {}, + info: () => {}, + warn: () => {}, +}; + describe('Targeting rule evaluator', () => { let targeting: Targeting; beforeAll(() => { - targeting = new Targeting(); + targeting = new Targeting(logger); }); it('should inject flag key as a property', () => { @@ -38,7 +46,7 @@ describe('String comparison operator', () => { let targeting: Targeting; beforeAll(() => { - targeting = new Targeting(); + targeting = new Targeting(logger); }); it('should evaluate starts with calls', () => { @@ -56,7 +64,7 @@ describe('String comparison operator should validate', () => { let targeting: Targeting; beforeAll(() => { - targeting = new Targeting(); + targeting = new Targeting(logger); }); it('missing input', () => { @@ -79,7 +87,7 @@ describe('Sem ver operator', () => { let targeting: Targeting; beforeAll(() => { - targeting = new Targeting(); + targeting = new Targeting(logger); }); it('should support equal operator', () => { @@ -142,7 +150,7 @@ describe('fractional operator', () => { let targeting: Targeting; beforeAll(() => { - targeting = new Targeting(); + targeting = new Targeting(logger); }); it('should evaluate valid rule', () => { @@ -177,7 +185,7 @@ describe('fractional operator should validate', () => { let targeting: Targeting; beforeAll(() => { - targeting = new Targeting(); + targeting = new Targeting(logger); }); it('bucket sum to be 100', () => { diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.ts index 9f37f25bd..bdd9e4350 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.ts @@ -1,25 +1,26 @@ import { LogicEngine } from 'json-logic-engine'; -import { endsWithHandler, endsWithRule, startsWithHandler, startsWithRule } from './string-comp'; -import { semVer, semVerRule } from './sem-ver'; -import { fractional, fractionalRule } from './fractional'; +import { stringCompareFactory, endsWithRule, startsWithRule } from './string-comp'; +import { semVerFactory, semVerRule } from './sem-ver'; +import { fractionalFactory, fractionalRule } from './fractional'; import { flagdPropertyKey, flagKeyPropertyKey, timestampPropertyKey } from './common'; - +import { type Logger } from '@openfeature/core'; export class Targeting { private readonly _logicEngine: LogicEngine; - constructor() { + constructor(private logger: Logger) { const engine = new LogicEngine(); + const { endsWithHandler, startsWithHandler } = stringCompareFactory(logger); engine.addMethod(startsWithRule, startsWithHandler); engine.addMethod(endsWithRule, endsWithHandler); - engine.addMethod(semVerRule, semVer); - engine.addMethod(fractionalRule, fractional); + engine.addMethod(semVerRule, semVerFactory(logger)); + engine.addMethod(fractionalRule, fractionalFactory(logger)); this._logicEngine = engine; } applyTargeting(flagKey: string, logic: unknown, data: object): unknown { if (Object.hasOwn(data, flagdPropertyKey)) { - console.warn(`overwriting ${flagdPropertyKey} property in the context`); + this.logger.warn(`overwriting ${flagdPropertyKey} property in the context`); } const ctxData = { diff --git a/release-please-config.json b/release-please-config.json index 03e8f5b2b..9216db503 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,6 +1,7 @@ { "bootstrap-sha": "8ec3eb4db55b7c2d6c2d7edc7c7f3c147b47fd35", "separate-pull-requests": true, + "plugins": ["node-workspace"], "packages": { "libs/hooks/open-telemetry": { "release-type": "node",