Skip to content

Commit f1ed82a

Browse files
authored
feat!: improve eval performance, restructure lib, support flag metadata (#1120)
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
1 parent 1f8679e commit f1ed82a

20 files changed

+1013
-612
lines changed

.gitmodules

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
[submodule "libs/providers/flagd/schemas"]
22
path = libs/providers/flagd/schemas
3-
url = https://github.com/open-feature/schemas.git
3+
url = https://github.com/open-feature/flagd-schemas.git
44
[submodule "libs/providers/flagd-web/schemas"]
55
path = libs/providers/flagd-web/schemas
6-
url = https://github.com/open-feature/schemas
6+
url = https://github.com/open-feature/flagd-schemas.git
77
[submodule "libs/providers/flagd/spec"]
88
path = libs/providers/flagd/spec
99
url = https://github.com/open-feature/spec.git
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
{
22
"name": "@openfeature/flagd-core",
33
"version": "0.2.5",
4+
"license": "Apache-2.0",
45
"scripts": {
56
"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",
67
"current-version": "echo $npm_package_version"
78
},
89
"peerDependencies": {
9-
"@openfeature/core": ">=0.0.16"
10+
"@openfeature/core": ">=1.6.0"
1011
},
1112
"dependencies": {
1213
"ajv": "^8.12.0",
1314
"tslib": "^2.3.0"
1415
}
15-
}
16+
}

libs/shared/flagd-core/src/lib/feature-flag.spec.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
import type { Logger } from '@openfeature/core';
12
import { FeatureFlag, Flag } from './feature-flag';
23

4+
const logger: Logger = {
5+
error: jest.fn(),
6+
warn: jest.fn(),
7+
info: jest.fn(),
8+
debug: jest.fn(),
9+
};
10+
311
describe('Flagd flag structure', () => {
412
it('should be constructed with valid input - boolean', () => {
513
const input: Flag = {
@@ -12,16 +20,35 @@ describe('Flagd flag structure', () => {
1220
targeting: '',
1321
};
1422

15-
const ff = new FeatureFlag(input);
23+
const ff = new FeatureFlag('test', input, logger);
1624

1725
expect(ff).toBeTruthy();
1826
expect(ff.state).toBe('ENABLED');
1927
expect(ff.defaultVariant).toBe('off');
20-
expect(ff.targeting).toBe('');
2128
expect(ff.variants.get('on')).toBeTruthy();
2229
expect(ff.variants.get('off')).toBeFalsy();
2330
});
2431

32+
it('should be constructed with valid input - string', () => {
33+
const input: Flag = {
34+
state: 'ENABLED',
35+
defaultVariant: 'off',
36+
variants: {
37+
on: 'on',
38+
off: 'off',
39+
},
40+
targeting: '',
41+
};
42+
43+
const ff = new FeatureFlag('test', input, logger);
44+
45+
expect(ff).toBeTruthy();
46+
expect(ff.state).toBe('ENABLED');
47+
expect(ff.defaultVariant).toBe('off');
48+
expect(ff.variants.get('on')).toBe('on');
49+
expect(ff.variants.get('off')).toBe('off');
50+
});
51+
2552
it('should be constructed with valid input - number', () => {
2653
const input: Flag = {
2754
state: 'ENABLED',
@@ -33,12 +60,11 @@ describe('Flagd flag structure', () => {
3360
targeting: '',
3461
};
3562

36-
const ff = new FeatureFlag(input);
63+
const ff = new FeatureFlag('test', input, logger);
3764

3865
expect(ff).toBeTruthy();
3966
expect(ff.state).toBe('ENABLED');
4067
expect(ff.defaultVariant).toBe('one');
41-
expect(ff.targeting).toBe('');
4268
expect(ff.variants.get('one')).toBe(1.0);
4369
expect(ff.variants.get('two')).toBe(2.0);
4470
});
@@ -60,12 +86,11 @@ describe('Flagd flag structure', () => {
6086
targeting: '',
6187
};
6288

63-
const ff = new FeatureFlag(input);
89+
const ff = new FeatureFlag('test', input, logger);
6490

6591
expect(ff).toBeTruthy();
6692
expect(ff.state).toBe('ENABLED');
6793
expect(ff.defaultVariant).toBe('pi2');
68-
expect(ff.targeting).toBe('');
6994
expect(ff.variants.get('pi2')).toStrictEqual({ value: 3.14, accuracy: 2 });
7095
expect(ff.variants.get('pi5')).toStrictEqual({ value: 3.14159, accuracy: 5 });
7196
});

libs/shared/flagd-core/src/lib/feature-flag.ts

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
import { FlagValue, ParseError } from '@openfeature/core';
1+
import type {
2+
FlagValue,
3+
FlagMetadata,
4+
ResolutionDetails,
5+
JsonValue,
6+
Logger,
7+
EvaluationContext,
8+
ResolutionReason,
9+
} from '@openfeature/core';
10+
import { ParseError, StandardResolutionReasons, ErrorCode } from '@openfeature/core';
211
import { sha1 } from 'object-hash';
12+
import { Targeting } from './targeting/targeting';
313

414
/**
515
* Flagd flag configuration structure mapping to schema definition.
@@ -9,27 +19,68 @@ export interface Flag {
919
defaultVariant: string;
1020
variants: { [key: string]: FlagValue };
1121
targeting?: string;
22+
metadata?: FlagMetadata;
1223
}
1324

25+
type RequiredResolutionDetails<T> = Omit<ResolutionDetails<T>, 'value'> & {
26+
flagMetadata: FlagMetadata;
27+
} & (
28+
| {
29+
reason: 'ERROR';
30+
errorCode: ErrorCode;
31+
errorMessage: string;
32+
value?: never;
33+
}
34+
| {
35+
value: T;
36+
variant: string;
37+
errorCode?: never;
38+
errorMessage?: never;
39+
}
40+
);
41+
1442
/**
1543
* Flagd flag configuration structure for internal reference.
1644
*/
1745
export class FeatureFlag {
46+
private readonly _key: string;
1847
private readonly _state: 'ENABLED' | 'DISABLED';
1948
private readonly _defaultVariant: string;
2049
private readonly _variants: Map<string, FlagValue>;
21-
private readonly _targeting: unknown;
2250
private readonly _hash: string;
51+
private readonly _metadata: FlagMetadata;
52+
private readonly _targeting?: Targeting;
53+
private readonly _targetingParseErrorMessage?: string;
2354

24-
constructor(flag: Flag) {
55+
constructor(
56+
key: string,
57+
flag: Flag,
58+
private readonly logger: Logger,
59+
) {
60+
this._key = key;
2561
this._state = flag['state'];
2662
this._defaultVariant = flag['defaultVariant'];
2763
this._variants = new Map<string, FlagValue>(Object.entries(flag['variants']));
28-
this._targeting = flag['targeting'];
64+
this._metadata = flag['metadata'] ?? {};
65+
66+
if (flag.targeting && Object.keys(flag.targeting).length > 0) {
67+
try {
68+
this._targeting = new Targeting(flag.targeting, logger);
69+
} catch (err) {
70+
const message = `Invalid targeting configuration for flag '${key}'`;
71+
this.logger.warn(message);
72+
this._targetingParseErrorMessage = message;
73+
}
74+
}
2975
this._hash = sha1(flag);
76+
3077
this.validateStructure();
3178
}
3279

80+
get key(): string {
81+
return this._key;
82+
}
83+
3384
get hash(): string {
3485
return this._hash;
3586
}
@@ -42,14 +93,73 @@ export class FeatureFlag {
4293
return this._defaultVariant;
4394
}
4495

45-
get targeting(): unknown {
46-
return this._targeting;
47-
}
48-
4996
get variants(): Map<string, FlagValue> {
5097
return this._variants;
5198
}
5299

100+
get metadata(): FlagMetadata {
101+
return this._metadata;
102+
}
103+
104+
evaluate(evalCtx: EvaluationContext, logger: Logger = this.logger): RequiredResolutionDetails<JsonValue> {
105+
let variant: string;
106+
let reason: ResolutionReason;
107+
108+
if (this._targetingParseErrorMessage) {
109+
return {
110+
reason: StandardResolutionReasons.ERROR,
111+
errorCode: ErrorCode.PARSE_ERROR,
112+
errorMessage: this._targetingParseErrorMessage,
113+
flagMetadata: this.metadata,
114+
};
115+
}
116+
117+
if (!this._targeting) {
118+
variant = this._defaultVariant;
119+
reason = StandardResolutionReasons.STATIC;
120+
} else {
121+
let targetingResolution: JsonValue;
122+
try {
123+
targetingResolution = this._targeting.evaluate(this._key, evalCtx, logger);
124+
} catch (e) {
125+
logger.debug(`Error evaluating targeting rule for flag '${this._key}': ${(e as Error).message}`);
126+
return {
127+
reason: StandardResolutionReasons.ERROR,
128+
errorCode: ErrorCode.GENERAL,
129+
errorMessage: `Error evaluating targeting rule for flag '${this._key}'`,
130+
flagMetadata: this.metadata,
131+
};
132+
}
133+
134+
// Return default variant if targeting resolution is null or undefined
135+
if (targetingResolution === null || targetingResolution === undefined) {
136+
variant = this._defaultVariant;
137+
reason = StandardResolutionReasons.DEFAULT;
138+
} else {
139+
// Obtain resolution in string. This is useful for short-circuiting json logic
140+
variant = targetingResolution.toString();
141+
reason = StandardResolutionReasons.TARGETING_MATCH;
142+
}
143+
}
144+
145+
const resolvedValue = this._variants.get(variant);
146+
if (resolvedValue === undefined) {
147+
return {
148+
reason: StandardResolutionReasons.ERROR,
149+
errorCode: ErrorCode.GENERAL,
150+
errorMessage: `Variant '${variant}' not found in flag with key '${this._key}'`,
151+
flagMetadata: this.metadata,
152+
};
153+
}
154+
155+
return {
156+
value: resolvedValue,
157+
reason,
158+
variant,
159+
flagMetadata: this.metadata,
160+
};
161+
}
162+
53163
validateStructure() {
54164
// basic validation, ideally this sort of thing is caught by IDEs and other schema validation before we get here
55165
// consistent with Java/Go and other implementations, we only warn for schema validation, but we fail for this sort of basic structural errors

0 commit comments

Comments
 (0)