Skip to content

Commit d8e7d54

Browse files
authored
support feature variant (#13)
1 parent b70bbd3 commit d8e7d54

File tree

9 files changed

+979
-66
lines changed

9 files changed

+979
-66
lines changed

src/IFeatureManager.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { ITargetingContext } from "./common/ITargetingContext";
5+
import { Variant } from "./variant/Variant";
6+
7+
export interface IFeatureManager {
8+
/**
9+
* Get the list of feature names.
10+
*/
11+
listFeatureNames(): Promise<string[]>;
12+
13+
/**
14+
* Check if a feature is enabled.
15+
* @param featureName name of the feature.
16+
* @param context an object providing information that can be used to evaluate whether a feature should be on or off.
17+
*/
18+
isEnabled(featureName: string, context?: unknown): Promise<boolean>;
19+
20+
/**
21+
* Get the allocated variant of a feature given the targeting context.
22+
* @param featureName name of the feature.
23+
* @param context a targeting context object used to evaluate which variant the user will be assigned.
24+
*/
25+
getVariant(featureName: string, context: ITargetingContext): Promise<Variant | undefined>;
26+
}

src/common/ITargetingContext.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
export interface ITargetingContext {
5+
userId?: string;
6+
groups?: string[];
7+
}
8+

src/common/targetingEvaluator.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { createHash } from "crypto";
5+
6+
/**
7+
* Determines if the user is part of the audience, based on the user id and the percentage range.
8+
*
9+
* @param userId user id from app context
10+
* @param hint hint string to be included in the context id
11+
* @param from percentage range start
12+
* @param to percentage range end
13+
* @returns true if the user is part of the audience, false otherwise
14+
*/
15+
export function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): boolean {
16+
if (from < 0 || from > 100) {
17+
throw new Error("The 'from' value must be between 0 and 100.");
18+
}
19+
if (to < 0 || to > 100) {
20+
throw new Error("The 'to' value must be between 0 and 100.");
21+
}
22+
if (from > to) {
23+
throw new Error("The 'from' value cannot be larger than the 'to' value.");
24+
}
25+
26+
const audienceContextId = constructAudienceContextId(userId, hint);
27+
28+
// Cryptographic hashing algorithms ensure adequate entropy across hash values.
29+
const contextMarker = stringToUint32(audienceContextId);
30+
const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100;
31+
32+
// Handle edge case of exact 100 bucket
33+
if (to === 100) {
34+
return contextPercentage >= from;
35+
}
36+
37+
return contextPercentage >= from && contextPercentage < to;
38+
}
39+
40+
/**
41+
* Determines if the user is part of the audience, based on the groups they belong to.
42+
*
43+
* @param sourceGroups user groups from app context
44+
* @param targetedGroups targeted groups from feature configuration
45+
* @returns true if the user is part of the audience, false otherwise
46+
*/
47+
export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGroups: string[]): boolean {
48+
if (sourceGroups === undefined) {
49+
return false;
50+
}
51+
52+
return sourceGroups.some(group => targetedGroups.includes(group));
53+
}
54+
55+
/**
56+
* Determines if the user is part of the audience, based on the user id.
57+
* @param userId user id from app context
58+
* @param users targeted users from feature configuration
59+
* @returns true if the user is part of the audience, false otherwise
60+
*/
61+
export function isTargetedUser(userId: string | undefined, users: string[]): boolean {
62+
if (userId === undefined) {
63+
return false;
64+
}
65+
66+
return users.includes(userId);
67+
}
68+
69+
/**
70+
* Constructs the context id for the audience.
71+
* The context id is used to determine if the user is part of the audience for a feature.
72+
*
73+
* @param userId userId from app context
74+
* @param hint hint string to be included in the context id
75+
* @returns a string that represents the context id for the audience
76+
*/
77+
function constructAudienceContextId(userId: string | undefined, hint: string): string {
78+
return `${userId ?? ""}\n${hint}`;
79+
}
80+
81+
/**
82+
* Converts a string to a uint32 in little-endian encoding.
83+
* @param str the string to convert.
84+
* @returns a uint32 value.
85+
*/
86+
function stringToUint32(str: string): number {
87+
// Create a SHA-256 hash of the string
88+
const hash = createHash("sha256").update(str).digest();
89+
90+
// Get the first 4 bytes of the hash
91+
const first4Bytes = hash.subarray(0, 4);
92+
93+
// Convert the 4 bytes to a uint32 with little-endian encoding
94+
const uint32 = first4Bytes.readUInt32LE(0);
95+
return uint32;
96+
}

src/featureManager.ts

Lines changed: 198 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33

44
import { TimeWindowFilter } from "./filter/TimeWindowFilter";
55
import { IFeatureFilter } from "./filter/FeatureFilter";
6-
import { RequirementType } from "./model";
6+
import { FeatureFlag, RequirementType, VariantDefinition } from "./model";
77
import { IFeatureFlagProvider } from "./featureProvider";
88
import { TargetingFilter } from "./filter/TargetingFilter";
9+
import { Variant } from "./variant/Variant";
10+
import { IFeatureManager } from "./IFeatureManager";
11+
import { ITargetingContext } from "./common/ITargetingContext";
12+
import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator";
913

10-
export class FeatureManager {
14+
export class FeatureManager implements IFeatureManager {
1115
#provider: IFeatureFlagProvider;
1216
#featureFilters: Map<string, IFeatureFilter> = new Map();
1317

@@ -30,15 +34,48 @@ export class FeatureManager {
3034

3135
// If multiple feature flags are found, the first one takes precedence.
3236
async isEnabled(featureName: string, context?: unknown): Promise<boolean> {
33-
const featureFlag = await this.#provider.getFeatureFlag(featureName);
34-
if (featureFlag === undefined) {
35-
// If the feature is not found, then it is disabled.
36-
return false;
37+
const result = await this.#evaluateFeature(featureName, context);
38+
return result.enabled;
39+
}
40+
41+
async getVariant(featureName: string, context?: ITargetingContext): Promise<Variant | undefined> {
42+
const result = await this.#evaluateFeature(featureName, context);
43+
return result.variant;
44+
}
45+
46+
async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise<VariantAssignment> {
47+
// user allocation
48+
if (featureFlag.allocation?.user !== undefined) {
49+
for (const userAllocation of featureFlag.allocation.user) {
50+
if (isTargetedUser(context.userId, userAllocation.users)) {
51+
return getVariantAssignment(featureFlag, userAllocation.variant, VariantAssignmentReason.User);
52+
}
53+
}
3754
}
3855

39-
// Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard.
40-
validateFeatureFlagFormat(featureFlag);
56+
// group allocation
57+
if (featureFlag.allocation?.group !== undefined) {
58+
for (const groupAllocation of featureFlag.allocation.group) {
59+
if (isTargetedGroup(context.groups, groupAllocation.groups)) {
60+
return getVariantAssignment(featureFlag, groupAllocation.variant, VariantAssignmentReason.Group);
61+
}
62+
}
63+
}
4164

65+
// percentile allocation
66+
if (featureFlag.allocation?.percentile !== undefined) {
67+
for (const percentileAllocation of featureFlag.allocation.percentile) {
68+
const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`;
69+
if (isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) {
70+
return getVariantAssignment(featureFlag, percentileAllocation.variant, VariantAssignmentReason.Percentile);
71+
}
72+
}
73+
}
74+
75+
return { variant: undefined, reason: VariantAssignmentReason.None };
76+
}
77+
78+
async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise<boolean> {
4279
if (featureFlag.enabled !== true) {
4380
// If the feature is not explicitly enabled, then it is disabled by default.
4481
return false;
@@ -61,7 +98,7 @@ export class FeatureManager {
6198

6299
for (const clientFilter of clientFilters) {
63100
const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name);
64-
const contextWithFeatureName = { featureName, parameters: clientFilter.parameters };
101+
const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters };
65102
if (matchedFeatureFilter === undefined) {
66103
console.warn(`Feature filter ${clientFilter.name} is not found.`);
67104
return false;
@@ -75,14 +112,166 @@ export class FeatureManager {
75112
return !shortCircuitEvaluationResult;
76113
}
77114

115+
async #evaluateFeature(featureName: string, context: unknown): Promise<EvaluationResult> {
116+
const featureFlag = await this.#provider.getFeatureFlag(featureName);
117+
const result = new EvaluationResult(featureFlag);
118+
119+
if (featureFlag === undefined) {
120+
return result;
121+
}
122+
123+
// Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard.
124+
// TODO: move to the feature flag provider implementation.
125+
validateFeatureFlagFormat(featureFlag);
126+
127+
// Evaluate if the feature is enabled.
128+
result.enabled = await this.#isEnabled(featureFlag, context);
129+
130+
// Determine Variant
131+
let variantDef: VariantDefinition | undefined;
132+
let reason: VariantAssignmentReason = VariantAssignmentReason.None;
133+
134+
// featureFlag.variant not empty
135+
if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) {
136+
if (!result.enabled) {
137+
// not enabled, assign default if specified
138+
if (featureFlag.allocation?.default_when_disabled !== undefined) {
139+
variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled);
140+
reason = VariantAssignmentReason.DefaultWhenDisabled;
141+
} else {
142+
// no default specified
143+
variantDef = undefined;
144+
reason = VariantAssignmentReason.DefaultWhenDisabled;
145+
}
146+
} else {
147+
// enabled, assign based on allocation
148+
if (context !== undefined && featureFlag.allocation !== undefined) {
149+
const variantAndReason = await this.#assignVariant(featureFlag, context as ITargetingContext);
150+
variantDef = variantAndReason.variant;
151+
reason = variantAndReason.reason;
152+
}
153+
154+
// allocation failed, assign default if specified
155+
if (variantDef === undefined && reason === VariantAssignmentReason.None) {
156+
if (featureFlag.allocation?.default_when_enabled !== undefined) {
157+
variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled);
158+
reason = VariantAssignmentReason.DefaultWhenEnabled;
159+
} else {
160+
variantDef = undefined;
161+
reason = VariantAssignmentReason.DefaultWhenEnabled;
162+
}
163+
}
164+
}
165+
}
166+
167+
// TODO: send telemetry for variant assignment reason in the future.
168+
console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`);
169+
170+
if (variantDef?.configuration_reference !== undefined) {
171+
console.warn("Configuration reference is not supported yet.");
172+
}
173+
174+
result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined;
175+
result.variantAssignmentReason = reason;
176+
177+
// Status override for isEnabled
178+
if (variantDef !== undefined && featureFlag.enabled) {
179+
if (variantDef.status_override === "Enabled") {
180+
result.enabled = true;
181+
} else if (variantDef.status_override === "Disabled") {
182+
result.enabled = false;
183+
}
184+
}
185+
186+
return result;
187+
}
78188
}
79189

80190
interface FeatureManagerOptions {
81191
customFilters?: IFeatureFilter[];
82192
}
83193

194+
/**
195+
* Validates the format of the feature flag definition.
196+
*
197+
* FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation.
198+
* Thus the properties are not guaranteed to have the expected types.
199+
*
200+
* @param featureFlag The feature flag definition to validate.
201+
*/
84202
function validateFeatureFlagFormat(featureFlag: any): void {
85203
if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") {
86204
throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`);
87205
}
206+
// TODO: add more validations.
207+
// TODO: should be moved to the feature flag provider.
208+
}
209+
210+
/**
211+
* Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None.
212+
*
213+
* @param featureFlag feature flag definition
214+
* @param variantName variant name
215+
* @param reason variant assignment reason
216+
* @returns variant assignment containing the variant definition and the reason
217+
*/
218+
function getVariantAssignment(featureFlag: FeatureFlag, variantName: string, reason: VariantAssignmentReason): VariantAssignment {
219+
const variant = featureFlag.variants?.find(v => v.name == variantName);
220+
if (variant !== undefined) {
221+
return { variant, reason };
222+
} else {
223+
console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`);
224+
return { variant: undefined, reason: VariantAssignmentReason.None };
225+
}
226+
}
227+
228+
type VariantAssignment = {
229+
variant: VariantDefinition | undefined;
230+
reason: VariantAssignmentReason;
231+
};
232+
233+
enum VariantAssignmentReason {
234+
/**
235+
* Variant allocation did not happen. No variant is assigned.
236+
*/
237+
None,
238+
239+
/**
240+
* The default variant is assigned when a feature flag is disabled.
241+
*/
242+
DefaultWhenDisabled,
243+
244+
/**
245+
* The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled.
246+
*/
247+
DefaultWhenEnabled,
248+
249+
/**
250+
* The variant is assigned because of the user allocation when a feature flag is enabled.
251+
*/
252+
User,
253+
254+
/**
255+
* The variant is assigned because of the group allocation when a feature flag is enabled.
256+
*/
257+
Group,
258+
259+
/**
260+
* The variant is assigned because of the percentile allocation when a feature flag is enabled.
261+
*/
262+
Percentile
263+
}
264+
265+
class EvaluationResult {
266+
constructor(
267+
// feature flag definition
268+
public readonly feature: FeatureFlag | undefined,
269+
270+
// enabled state
271+
public enabled: boolean = false,
272+
273+
// variant assignment
274+
public variant: Variant | undefined = undefined,
275+
public variantAssignmentReason: VariantAssignmentReason = VariantAssignmentReason.None
276+
) { }
88277
}

0 commit comments

Comments
 (0)