Skip to content

Commit add328b

Browse files
committed
feat: improve eval performance, restructure lib, support flag metadata
Signed-off-by: Michael Beemer <beeme1mr@users.noreply.github.com>
1 parent 456be7c commit add328b

21 files changed

+2927
-2120
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: 125 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,66 @@ 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+
}
38+
);
39+
1440
/**
1541
* Flagd flag configuration structure for internal reference.
1642
*/
1743
export class FeatureFlag {
44+
private readonly _key: string;
1845
private readonly _state: 'ENABLED' | 'DISABLED';
1946
private readonly _defaultVariant: string;
2047
private readonly _variants: Map<string, FlagValue>;
21-
private readonly _targeting: unknown;
2248
private readonly _hash: string;
49+
private readonly _metadata: FlagMetadata;
50+
private readonly _targeting?: Targeting;
51+
private readonly _targetingParseErrorMessage?: string;
2352

24-
constructor(flag: Flag) {
53+
constructor(
54+
key: string,
55+
flag: Flag,
56+
private readonly logger: Logger,
57+
) {
58+
this._key = key;
2559
this._state = flag['state'];
2660
this._defaultVariant = flag['defaultVariant'];
2761
this._variants = new Map<string, FlagValue>(Object.entries(flag['variants']));
28-
this._targeting = flag['targeting'];
62+
this._metadata = flag['metadata'] ?? {};
63+
64+
if (flag.targeting && Object.keys(flag.targeting).length > 0) {
65+
try {
66+
this._targeting = new Targeting(flag.targeting, logger);
67+
} catch (err) {
68+
const message = `Invalid targeting configuration for flag '${key}'`;
69+
this.logger.warn(message);
70+
this._targetingParseErrorMessage = message;
71+
}
72+
}
2973
this._hash = sha1(flag);
74+
3075
this.validateStructure();
3176
}
3277

78+
get key(): string {
79+
return this._key;
80+
}
81+
3382
get hash(): string {
3483
return this._hash;
3584
}
@@ -42,14 +91,82 @@ export class FeatureFlag {
4291
return this._defaultVariant;
4392
}
4493

45-
get targeting(): unknown {
46-
return this._targeting;
47-
}
48-
4994
get variants(): Map<string, FlagValue> {
5095
return this._variants;
5196
}
5297

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