Skip to content

feat!: improve eval performance, restructure lib, support flag metadata #1120

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
124 changes: 116 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,73 @@ 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;
}
}

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

return {
value: resolvedValue,
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
Loading