Skip to content

Commit

Permalink
feat: improve eval performance, restructure lib, support flag metadata
Browse files Browse the repository at this point in the history
Signed-off-by: Michael Beemer <[email protected]>
  • Loading branch information
beeme1mr committed Jan 6, 2025
1 parent 456be7c commit add328b
Show file tree
Hide file tree
Showing 21 changed files with 2,927 additions and 2,120 deletions.
4 changes: 2 additions & 2 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[submodule "libs/providers/flagd/schemas"]
path = libs/providers/flagd/schemas
url = https://github.com/open-feature/schemas.git
url = https://github.com/open-feature/flagd-schemas.git
[submodule "libs/providers/flagd-web/schemas"]
path = libs/providers/flagd-web/schemas
url = https://github.com/open-feature/schemas
url = https://github.com/open-feature/flagd-schemas.git
[submodule "libs/providers/flagd/spec"]
path = libs/providers/flagd/spec
url = https://github.com/open-feature/spec.git
Expand Down
5 changes: 3 additions & 2 deletions libs/shared/flagd-core/package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
{
"name": "@openfeature/flagd-core",
"version": "0.2.5",
"license": "Apache-2.0",
"scripts": {
"publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi",
"current-version": "echo $npm_package_version"
},
"peerDependencies": {
"@openfeature/core": ">=0.0.16"
"@openfeature/core": ">=1.6.0"
},
"dependencies": {
"ajv": "^8.12.0",
"tslib": "^2.3.0"
}
}
}
37 changes: 31 additions & 6 deletions libs/shared/flagd-core/src/lib/feature-flag.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { Logger } from '@openfeature/core';
import { FeatureFlag, Flag } from './feature-flag';

const logger: Logger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};

describe('Flagd flag structure', () => {
it('should be constructed with valid input - boolean', () => {
const input: Flag = {
Expand All @@ -12,16 +20,35 @@ describe('Flagd flag structure', () => {
targeting: '',
};

const ff = new FeatureFlag(input);
const ff = new FeatureFlag('test', input, logger);

expect(ff).toBeTruthy();
expect(ff.state).toBe('ENABLED');
expect(ff.defaultVariant).toBe('off');
expect(ff.targeting).toBe('');
expect(ff.variants.get('on')).toBeTruthy();
expect(ff.variants.get('off')).toBeFalsy();
});

it('should be constructed with valid input - string', () => {
const input: Flag = {
state: 'ENABLED',
defaultVariant: 'off',
variants: {
on: 'on',
off: 'off',
},
targeting: '',
};

const ff = new FeatureFlag('test', input, logger);

expect(ff).toBeTruthy();
expect(ff.state).toBe('ENABLED');
expect(ff.defaultVariant).toBe('off');
expect(ff.variants.get('on')).toBe('on');
expect(ff.variants.get('off')).toBe('off');
});

it('should be constructed with valid input - number', () => {
const input: Flag = {
state: 'ENABLED',
Expand All @@ -33,12 +60,11 @@ describe('Flagd flag structure', () => {
targeting: '',
};

const ff = new FeatureFlag(input);
const ff = new FeatureFlag('test', input, logger);

expect(ff).toBeTruthy();
expect(ff.state).toBe('ENABLED');
expect(ff.defaultVariant).toBe('one');
expect(ff.targeting).toBe('');
expect(ff.variants.get('one')).toBe(1.0);
expect(ff.variants.get('two')).toBe(2.0);
});
Expand All @@ -60,12 +86,11 @@ describe('Flagd flag structure', () => {
targeting: '',
};

const ff = new FeatureFlag(input);
const ff = new FeatureFlag('test', input, logger);

expect(ff).toBeTruthy();
expect(ff.state).toBe('ENABLED');
expect(ff.defaultVariant).toBe('pi2');
expect(ff.targeting).toBe('');
expect(ff.variants.get('pi2')).toStrictEqual({ value: 3.14, accuracy: 2 });
expect(ff.variants.get('pi5')).toStrictEqual({ value: 3.14159, accuracy: 5 });
});
Expand Down
133 changes: 125 additions & 8 deletions libs/shared/flagd-core/src/lib/feature-flag.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { FlagValue, ParseError } from '@openfeature/core';
import type {
FlagValue,
FlagMetadata,
ResolutionDetails,
JsonValue,
Logger,
EvaluationContext,
ResolutionReason,
} from '@openfeature/core';
import { ParseError, StandardResolutionReasons, ErrorCode } from '@openfeature/core';
import { sha1 } from 'object-hash';
import { Targeting } from './targeting/targeting';

/**
* Flagd flag configuration structure mapping to schema definition.
Expand All @@ -9,27 +19,66 @@ export interface Flag {
defaultVariant: string;
variants: { [key: string]: FlagValue };
targeting?: string;
metadata?: FlagMetadata;
}

type RequiredResolutionDetails<T> = Omit<ResolutionDetails<T>, 'value'> & {
flagMetadata: FlagMetadata;
} & (
| {
reason: 'ERROR';
errorCode: ErrorCode;
errorMessage: string;
value?: never;
}
| {
value: T;
variant: string;
}
);

/**
* Flagd flag configuration structure for internal reference.
*/
export class FeatureFlag {
private readonly _key: string;
private readonly _state: 'ENABLED' | 'DISABLED';
private readonly _defaultVariant: string;
private readonly _variants: Map<string, FlagValue>;
private readonly _targeting: unknown;
private readonly _hash: string;
private readonly _metadata: FlagMetadata;
private readonly _targeting?: Targeting;
private readonly _targetingParseErrorMessage?: string;

constructor(flag: Flag) {
constructor(
key: string,
flag: Flag,
private readonly logger: Logger,
) {
this._key = key;
this._state = flag['state'];
this._defaultVariant = flag['defaultVariant'];
this._variants = new Map<string, FlagValue>(Object.entries(flag['variants']));
this._targeting = flag['targeting'];
this._metadata = flag['metadata'] ?? {};

if (flag.targeting && Object.keys(flag.targeting).length > 0) {
try {
this._targeting = new Targeting(flag.targeting, logger);
} catch (err) {
const message = `Invalid targeting configuration for flag '${key}'`;
this.logger.warn(message);
this._targetingParseErrorMessage = message;
}
}
this._hash = sha1(flag);

this.validateStructure();
}

get key(): string {
return this._key;
}

get hash(): string {
return this._hash;
}
Expand All @@ -42,14 +91,82 @@ export class FeatureFlag {
return this._defaultVariant;
}

get targeting(): unknown {
return this._targeting;
}

get variants(): Map<string, FlagValue> {
return this._variants;
}

get metadata(): FlagMetadata {
return this._metadata;
}

evaluate(evalCtx: EvaluationContext, logger: Logger = this.logger): RequiredResolutionDetails<JsonValue> {
let variant: string;
let reason: ResolutionReason;

if (this._targetingParseErrorMessage) {
return {
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.PARSE_ERROR,
errorMessage: this._targetingParseErrorMessage,
flagMetadata: this.metadata,
};
}

if (!this._targeting) {
variant = this._defaultVariant;
reason = StandardResolutionReasons.STATIC;
} else {
let targetingResolution: JsonValue;
try {
targetingResolution = this._targeting.evaluate(this._key, evalCtx, logger);
} catch (e) {
logger.debug(`Error evaluating targeting rule for flag '${this._key}': ${(e as Error).message}`);
return {
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: `Error evaluating targeting rule for flag '${this._key}'`,
flagMetadata: this.metadata,
};
}

// Return default variant if targeting resolution is null or undefined
if (targetingResolution === null || targetingResolution === undefined) {
variant = this._defaultVariant;
reason = StandardResolutionReasons.DEFAULT;
} else {
// Obtain resolution in string. This is useful for short-circuiting json logic
variant = targetingResolution.toString();
reason = StandardResolutionReasons.TARGETING_MATCH;
}
}

// if (typeof variant !== 'string') {
// return {
// reason: StandardResolutionReasons.ERROR,
// errorCode: ErrorCode.GENERAL,
// errorMessage: `Variant must be a string, but found '${typeof variant}'`,
// flagMetadata: this.metadata,
// };
// }

const resolvedVariant = this._variants.get(variant);
if (resolvedVariant === undefined) {
return {
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
errorMessage: `Variant '${variant}' not found in flag with key '${this._key}'`,
flagMetadata: this.metadata,
};
}

return {
value: resolvedVariant,
reason,
variant,
flagMetadata: this.metadata,
};
}

validateStructure() {
// basic validation, ideally this sort of thing is caught by IDEs and other schema validation before we get here
// consistent with Java/Go and other implementations, we only warn for schema validation, but we fail for this sort of basic structural errors
Expand Down
Loading

0 comments on commit add328b

Please sign in to comment.