Skip to content

Commit b8a9bd5

Browse files
committed
feat: enhance flag evaluation and metadata handling in MemoryStorage
Signed-off-by: Michael Beemer <[email protected]>
1 parent f63ba61 commit b8a9bd5

File tree

7 files changed

+127
-53
lines changed

7 files changed

+127
-53
lines changed

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface Flag {
2222
metadata?: FlagMetadata;
2323
}
2424

25+
type ResolutionDetailsWithFlagMetadata<T> = Required<Pick<ResolutionDetails<T>, 'flagMetadata'>> & ResolutionDetails<T>;
26+
2527
/**
2628
* Flagd flag configuration structure for internal reference.
2729
*/
@@ -33,15 +35,24 @@ export class FeatureFlag {
3335
private readonly _hash: string;
3436
private readonly _metadata: FlagMetadata;
3537
private readonly _targeting?: Targeting;
38+
private readonly _targetingParseError?: Error;
3639

3740
constructor(key: string, flag: Flag, logger: Logger) {
3841
this._key = key;
3942
this._state = flag['state'];
4043
this._defaultVariant = flag['defaultVariant'];
4144
this._variants = new Map<string, FlagValue>(Object.entries(flag['variants']));
4245
this._metadata = flag['metadata'] ?? {};
43-
this._targeting =
44-
flag.targeting && Object.keys(flag.targeting).length > 0 ? new Targeting(flag.targeting, logger) : undefined;
46+
47+
if (flag.targeting && Object.keys(flag.targeting).length > 0) {
48+
try {
49+
this._targeting = new Targeting(flag.targeting, logger);
50+
} catch (err) {
51+
const message = `Invalid targeting configuration for flag '${key}'`;
52+
logger.warn(message);
53+
this._targetingParseError = new ParseError(message, { cause: err });
54+
}
55+
}
4556
this._hash = sha1(flag);
4657

4758
this.validateStructure();
@@ -71,24 +82,25 @@ export class FeatureFlag {
7182
return this._metadata;
7283
}
7384

74-
evaluate(evalCtx: EvaluationContext): ResolutionDetails<JsonValue> {
85+
evaluate(evalCtx: EvaluationContext): ResolutionDetailsWithFlagMetadata<JsonValue> {
7586
let variant: string;
7687
let reason: ResolutionReason;
7788

78-
if (!this._targeting) {
89+
if (this._targetingParseError) {
90+
throw this._targetingParseError;
91+
} else if (!this._targeting) {
7992
variant = this._defaultVariant;
8093
reason = StandardResolutionReasons.STATIC;
8194
} else {
8295
let targetingResolution: JsonValue;
8396
try {
8497
targetingResolution = this._targeting.evaluate(this._key, evalCtx);
8598
} catch (e) {
86-
console.log(e);
8799
throw new GeneralError(`Error evaluating targeting rule for flag '${this._key}'`, { cause: e });
88100
}
89101

90102
// Return default variant if targeting resolution is null or undefined
91-
if (targetingResolution == null) {
103+
if (targetingResolution === null || targetingResolution === undefined) {
92104
variant = this._defaultVariant;
93105
reason = StandardResolutionReasons.DEFAULT;
94106
} else {

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,12 @@ describe('flagd-core validations', () => {
155155
expect(evaluation.variant).toBeUndefined();
156156
});
157157

158-
it('should return reason "disabled" and return the default value', () => {
158+
it('should return reason "error" because the flag is disabled', () => {
159159
const flagKey = 'disabledFlag';
160160
const evaluation = core.resolveBooleanEvaluation(flagKey, false, {});
161-
expect(evaluation.reason).toBe(StandardResolutionReasons.DISABLED);
161+
expect(evaluation.reason).toBe(StandardResolutionReasons.ERROR);
162+
expect(evaluation.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND);
163+
expect(evaluation.errorMessage).toBe(`flag '${flagKey}' is disabled`);
162164
expect(evaluation.value).toBe(false);
163165
expect(evaluation.variant).toBeUndefined();
164166
});

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

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
import { FeatureFlag } from './feature-flag';
1515
import { MemoryStorage, Storage } from './storage';
1616

17+
type ResolutionDetailsWithFlagMetadata<T> = Required<Pick<ResolutionDetails<T>, 'flagMetadata'>> & ResolutionDetails<T>;
18+
1719
/**
1820
* Expose flag configuration setter and flag resolving methods.
1921
*/
@@ -26,16 +28,6 @@ export class FlagdCore implements Storage {
2628
this._storage = storage ? storage : new MemoryStorage(this._logger);
2729
}
2830

29-
/**
30-
* Sets the logger for the FlagdCore instance.
31-
* @param logger - The logger to be set.
32-
* @returns - The FlagdCore instance with the logger set.
33-
*/
34-
setLogger(logger: Logger) {
35-
this._logger = new SafeLogger(logger);
36-
return this;
37-
}
38-
3931
setConfigurations(cfg: string): string[] {
4032
return this._storage.setConfigurations(cfg);
4133
}
@@ -48,20 +40,22 @@ export class FlagdCore implements Storage {
4840
return this._storage.getFlags();
4941
}
5042

43+
getFlagSetMetadata(): { flagSetId?: string; flagSetVersion?: string } {
44+
return this._storage.getFlagSetMetadata();
45+
}
46+
5147
/**
5248
* Resolve the flag evaluation to a boolean value.
5349
* @param flagKey - The key of the flag to be evaluated.
5450
* @param defaultValue - The default value to be returned if the flag is not found.
5551
* @param evalCtx - The evaluation context to be used for targeting.
56-
* @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger.
5752
* @returns - The resolved value and the reason for the resolution.
5853
*/
5954
resolveBooleanEvaluation(
6055
flagKey: string,
6156
defaultValue: boolean,
6257
evalCtx?: EvaluationContext,
63-
// logger?: Logger,
64-
): ResolutionDetails<boolean> {
58+
): ResolutionDetailsWithFlagMetadata<boolean> {
6559
return this.resolve('boolean', flagKey, defaultValue, evalCtx);
6660
}
6761

@@ -70,15 +64,13 @@ export class FlagdCore implements Storage {
7064
* @param flagKey - The key of the flag to be evaluated.
7165
* @param defaultValue - The default value to be returned if the flag is not found.
7266
* @param evalCtx - The evaluation context to be used for targeting.
73-
* @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger.
7467
* @returns - The resolved value and the reason for the resolution.
7568
*/
7669
resolveStringEvaluation(
7770
flagKey: string,
7871
defaultValue: string,
7972
evalCtx?: EvaluationContext,
80-
// logger?: Logger,
81-
): ResolutionDetails<string> {
73+
): ResolutionDetailsWithFlagMetadata<string> {
8274
return this.resolve('string', flagKey, defaultValue, evalCtx);
8375
}
8476

@@ -87,15 +79,13 @@ export class FlagdCore implements Storage {
8779
* @param flagKey - The key of the flag to evaluate.
8880
* @param defaultValue - The default value to return if the flag is not found or the evaluation fails.
8981
* @param evalCtx - The evaluation context to be used for targeting.
90-
* @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger.
9182
* @returns - The resolved value and the reason for the resolution.
9283
*/
9384
resolveNumberEvaluation(
9485
flagKey: string,
9586
defaultValue: number,
9687
evalCtx?: EvaluationContext,
97-
// logger?: Logger,
98-
): ResolutionDetails<number> {
88+
): ResolutionDetailsWithFlagMetadata<number> {
9989
return this.resolve('number', flagKey, defaultValue, evalCtx);
10090
}
10191

@@ -105,26 +95,22 @@ export class FlagdCore implements Storage {
10595
* @param flagKey - The key of the flag to resolve.
10696
* @param defaultValue - The default value to use if the flag is not found.
10797
* @param evalCtx - The evaluation context to be used for targeting.
108-
* @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger.
10998
* @returns - The resolved value and the reason for the resolution.
11099
*/
111100
resolveObjectEvaluation<T extends JsonValue>(
112101
flagKey: string,
113102
defaultValue: T,
114103
evalCtx?: EvaluationContext,
115-
// logger?: Logger,
116-
): ResolutionDetails<T> {
104+
): ResolutionDetailsWithFlagMetadata<T> {
117105
return this.resolve('object', flagKey, defaultValue, evalCtx);
118106
}
119107

120108
/**
121109
* Resolve the flag evaluation for all enabled flags.
122110
* @param evalCtx - The evaluation context to be used for targeting.
123-
* @param logger - The logger to be used to troubleshoot targeting errors. Overrides the default logger.
124111
* @returns - The list of evaluation details for all enabled flags.
125112
*/
126113
resolveAll(evalCtx: EvaluationContext = {}): EvaluationDetails<JsonValue>[] {
127-
// logger ??= this._logger;
128114
const values: EvaluationDetails<JsonValue>[] = [];
129115
for (const [key, flag] of this.getFlags()) {
130116
try {
@@ -151,7 +137,6 @@ export class FlagdCore implements Storage {
151137
* @param {string} flagKey - The key of the flag.
152138
* @param {T} defaultValue - The default value of the flag.
153139
* @param {EvaluationContext} evalCtx - The evaluation context for targeting rules.
154-
* @param {Logger} [logger] - The optional logger for logging errors.
155140
* @returns {ResolutionDetails<T>} - The resolved value and the reason for the resolution.
156141
* @throws {FlagNotFoundError} - If the flag with the given key is not found.
157142
* @throws {TypeMismatchError} - If the evaluated type of the flag does not match the expected type.
@@ -162,8 +147,7 @@ export class FlagdCore implements Storage {
162147
flagKey: string,
163148
defaultValue: T,
164149
evalCtx: EvaluationContext = {},
165-
// logger?: Logger,
166-
): ResolutionDetails<T> {
150+
): ResolutionDetailsWithFlagMetadata<T> {
167151
const flag = this._storage.getFlag(flagKey);
168152
// flag exist check
169153
if (!flag) {
@@ -172,14 +156,17 @@ export class FlagdCore implements Storage {
172156
reason: StandardResolutionReasons.ERROR,
173157
errorCode: ErrorCode.FLAG_NOT_FOUND,
174158
errorMessage: `flag '${flagKey}' not found`,
159+
flagMetadata: this._storage.getFlagSetMetadata(),
175160
};
176161
}
177162

178163
// flag status check
179164
if (flag.state === 'DISABLED') {
180165
return {
181166
value: defaultValue,
182-
reason: StandardResolutionReasons.DISABLED,
167+
reason: StandardResolutionReasons.ERROR,
168+
errorCode: ErrorCode.FLAG_NOT_FOUND,
169+
errorMessage: `flag '${flagKey}' is disabled`,
183170
flagMetadata: flag.metadata,
184171
};
185172
}
@@ -200,6 +187,7 @@ export class FlagdCore implements Storage {
200187
value: value as T,
201188
reason,
202189
variant,
190+
flagMetadata: flag.metadata,
203191
};
204192
}
205193
}

libs/shared/flagd-core/src/lib/parser.spec.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('Flag configurations', () => {
2525
' }\n' +
2626
'}';
2727

28-
const flags = parse(simpleFlag, false, logger);
28+
const { flags } = parse(simpleFlag, false, logger);
2929
expect(flags).toBeTruthy();
3030
expect(flags.get('myBoolFlag')).toBeTruthy();
3131
});
@@ -34,7 +34,7 @@ describe('Flag configurations', () => {
3434
const longFlag =
3535
'{"flags":{"myBoolFlag":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"myStringFlag":{"state":"ENABLED","variants":{"key1":"val1","key2":"val2"},"defaultVariant":"key1"},"myFloatFlag":{"state":"ENABLED","variants":{"one":1.23,"two":2.34},"defaultVariant":"one"},"myIntFlag":{"state":"ENABLED","variants":{"one":1,"two":2},"defaultVariant":"one"},"myObjectFlag":{"state":"ENABLED","variants":{"object1":{"key":"val"},"object2":{"key":true}},"defaultVariant":"object1"},"fibAlgo":{"variants":{"recursive":"recursive","memo":"memo","loop":"loop","binet":"binet"},"defaultVariant":"recursive","state":"ENABLED","targeting":{"if":[{"$ref":"emailWithFaas"},"binet",null]}},"targetedFlag":{"variants":{"first":"AAA","second":"BBB","third":"CCC"},"defaultVariant":"first","state":"ENABLED","targeting":{"if":[{"in":["@openfeature.dev",{"var":"email"}]},"second",{"in":["Chrome",{"var":"userAgent"}]},"third",null]}}},"$evaluators":{"emailWithFaas":{"in":["@faas.com",{"var":["email"]}]}}}';
3636

37-
const flags = parse(longFlag, false, logger);
37+
const { flags } = parse(longFlag, false, logger);
3838
expect(flags).toBeTruthy();
3939
expect(flags.get('myBoolFlag')).toBeTruthy();
4040
expect(flags.get('myStringFlag')).toBeTruthy();
@@ -61,12 +61,13 @@ describe('Flag configurations', () => {
6161
const flagWithRef =
6262
'{"flags":{"fibAlgo":{"variants":{"recursive":"recursive","binet":"binet"},"defaultVariant":"recursive","state":"ENABLED","targeting":{"if":[{"$ref":"emailSuffixFaas"},"binet",null]}}},"$evaluators":{"emailSuffixFaas":{"in":["@faas.com",{"var":["email"]}]}}}';
6363

64-
const flags = parse(flagWithRef, false, logger);
64+
const { flags } = parse(flagWithRef, false, logger);
6565
expect(flags).toBeTruthy();
6666

6767
const fibAlgo = flags.get('fibAlgo');
6868
expect(fibAlgo).toBeTruthy();
69-
// expect(fibAlgo?.targeting).toStrictEqual({ if: [{ in: ['@faas.com', { var: ['email'] }] }, 'binet', null] });
69+
// TODO - spy on parser
70+
// expect(fibAlgo.).toStrictEqual({ if: [{ in: ['@faas.com', { var: ['email'] }] }, 'binet', null] });
7071
});
7172

7273
it('should throw a parsing error due to invalid JSON', () => {
@@ -120,5 +121,24 @@ describe('Flag configurations', () => {
120121

121122
expect(() => parse(invalidFlag, true, logger)).toThrow(ParseError);
122123
});
124+
125+
it('should not throw if targeting is valid', () => {
126+
const invalidFlag =
127+
'{\n' +
128+
' "flags": {\n' +
129+
' "myBoolFlag": {\n' +
130+
' "state": "ENABLED",\n' +
131+
' "variants": {\n' +
132+
' "on": true,\n' +
133+
' "off": false\n' +
134+
' },\n' +
135+
' "defaultVariant": "off",\n' +
136+
' "targeting": {"if":[{"in":[{"var":"locale"},["us","ca"]]}, "true"]}' +
137+
' }\n' +
138+
' }\n' +
139+
'}';
140+
141+
expect(() => parse(invalidFlag, true, logger)).not.toThrow();
142+
});
123143
});
124144
});

libs/shared/flagd-core/src/lib/parser.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import flagsSchema from '../../flagd-schemas/json/flags.json';
55
import targetingSchema from '../../flagd-schemas/json/targeting.json';
66
import { FeatureFlag, Flag } from './feature-flag';
77

8+
type FlagConfig = {
9+
flags: { [key: string]: Flag };
10+
metadata?: { id?: string; version?: string };
11+
};
12+
13+
type FlagSet = {
14+
flags: Map<string, FeatureFlag>;
15+
metadata: { flagSetId?: string; flagSetVersion?: string };
16+
};
17+
818
const ajv = new Ajv({ strict: false });
919
const validate = ajv.addSchema(targetingSchema).compile(flagsSchema);
1020

@@ -16,28 +26,28 @@ const errorMessages = 'invalid flagd flag configuration';
1626
/**
1727
* Validate and parse flag configurations.
1828
*/
19-
export function parse(flagCfg: string, throwIfSchemaInvalid: boolean, logger: Logger): Map<string, FeatureFlag> {
29+
export function parse(flagConfig: string, throwIfSchemaInvalid: boolean, logger: Logger): FlagSet {
2030
try {
21-
const transformed = transform(flagCfg);
22-
const flags: { flags: { [key: string]: Flag }; metadata?: { id?: string; version?: string } } =
23-
JSON.parse(transformed);
24-
const isValid = validate(flags);
31+
const transformed = transform(flagConfig);
32+
const parsedFlagConfig: FlagConfig = JSON.parse(transformed);
33+
34+
const isValid = validate(parsedFlagConfig);
2535
if (!isValid) {
2636
const message = `${errorMessages}: ${JSON.stringify(validate.errors, undefined, 2)}`;
27-
logger.warn(message);
37+
// TODO see if trace logging makes sense here
2838
if (throwIfSchemaInvalid) {
2939
throw new ParseError(message);
3040
}
3141
}
3242
const flagMap = new Map<string, FeatureFlag>();
3343

3444
const flagSetMetadata = {
35-
...(flags?.metadata?.id && { flagSetId: flags.metadata.id }),
36-
...(flags?.metadata?.version && { flagSetVersion: flags.metadata.version }),
45+
...(parsedFlagConfig?.metadata?.id && { flagSetId: parsedFlagConfig.metadata.id }),
46+
...(parsedFlagConfig?.metadata?.version && { flagSetVersion: parsedFlagConfig.metadata.version }),
3747
};
3848

39-
for (const flagsKey in flags.flags) {
40-
const flag = flags.flags[flagsKey];
49+
for (const flagsKey in parsedFlagConfig.flags) {
50+
const flag = parsedFlagConfig.flags[flagsKey];
4151
flagMap.set(
4252
flagsKey,
4353
new FeatureFlag(
@@ -54,12 +64,15 @@ export function parse(flagCfg: string, throwIfSchemaInvalid: boolean, logger: Lo
5464
);
5565
}
5666

57-
return flagMap;
67+
return {
68+
flags: flagMap,
69+
metadata: flagSetMetadata,
70+
};
5871
} catch (err) {
5972
if (err instanceof ParseError) {
6073
throw err;
6174
}
62-
throw new ParseError(errorMessages);
75+
throw new ParseError(errorMessages, { cause: err });
6376
}
6477
}
6578

0 commit comments

Comments
 (0)