From 504b6e024df4a5aed710daa58d84a09720cb615d Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Thu, 4 Jul 2024 15:52:01 +0800 Subject: [PATCH 1/9] support feature variant --- src/IFeatureManager.ts | 7 ++ src/common/ITargetingContext.ts | 8 ++ src/common/targetingEvaluator.ts | 92 ++++++++++++++++ src/featureManager.ts | 153 +++++++++++++++++++++++++- src/filter/TargetingFilter.ts | 62 ++--------- src/model.ts | 4 +- src/variant/IVariantFeatureManager.ts | 6 + src/variant/Variant.ts | 9 ++ 8 files changed, 281 insertions(+), 60 deletions(-) create mode 100644 src/IFeatureManager.ts create mode 100644 src/common/ITargetingContext.ts create mode 100644 src/common/targetingEvaluator.ts create mode 100644 src/variant/IVariantFeatureManager.ts create mode 100644 src/variant/Variant.ts diff --git a/src/IFeatureManager.ts b/src/IFeatureManager.ts new file mode 100644 index 0000000..9260f73 --- /dev/null +++ b/src/IFeatureManager.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface IFeatureManager { + listFeatureNames(): Promise; + isEnabled(featureName: string, context?: unknown): Promise; +} diff --git a/src/common/ITargetingContext.ts b/src/common/ITargetingContext.ts new file mode 100644 index 0000000..758ec52 --- /dev/null +++ b/src/common/ITargetingContext.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface ITargetingContext { + userId?: string; + groups?: string[]; +} + diff --git a/src/common/targetingEvaluator.ts b/src/common/targetingEvaluator.ts new file mode 100644 index 0000000..bc04b61 --- /dev/null +++ b/src/common/targetingEvaluator.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createHash } from "crypto"; + +/** + * Determines if the user is part of the audience, based on the user id and the percentage range. + * + * @param userId user id from app context + * @param hint hint string to be included in the context id + * @param from percentage range start + * @param to percentage range end + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): boolean { + const audienceContextId = constructAudienceContextId(userId, hint); + + // Cryptographic hashing algorithms ensure adequate entropy across hash values. + const contextMarker = stringToUint32(audienceContextId); + const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; + + // Handle edge case of exact 100 bucket + if (to === 100) { + return contextPercentage >= from; + } + + return contextPercentage >= from && contextPercentage < to; +} + +/** + * Determines if the user is part of the audience, based on the groups they belong to. + * + * @param sourceGroups user groups from app context + * @param targetedGroups targeted groups from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGroups: string[]): boolean { + if (sourceGroups === undefined) { + return false; + } + + for (const group of sourceGroups) { + if (targetedGroups.includes(group)) { + return true; + } + } + + return false; +} + +/** + * Determines if the user is part of the audience, based on the user id. + * @param userId user id from app context + * @param users targeted users from feature configuration + * @returns true if the user is part of the audience, false otherwise + */ +export function isTargetedUser(userId: string | undefined, users: string[]): boolean { + if (userId === undefined) { + return false; + } + + return users.includes(userId); +} + +/** + * Constructs the context id for the audience. + * The context id is used to determine if the user is part of the audience for a feature. + * + * @param userId userId from app context + * @param hint hint string to be included in the context id + * @returns a string that represents the context id for the audience + */ +function constructAudienceContextId(userId: string | undefined, hint: string): string { + return `${userId ?? ""}\n${hint}`; +} + +/** + * Converts a string to a uint32 in little-endian encoding. + * @param str The string to convert. + * @returns The uint32 value. + */ +function stringToUint32(str: string): number { + // Create a SHA-256 hash of the string + const hash = createHash("sha256").update(str).digest(); + + // Get the first 4 bytes of the hash + const first4Bytes = hash.subarray(0, 4); + + // Convert the 4 bytes to a uint32 with little-endian encoding + const uint32 = first4Bytes.readUInt32LE(0); + return uint32; +} diff --git a/src/featureManager.ts b/src/featureManager.ts index bce1325..c0c6c90 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -3,11 +3,16 @@ import { TimeWindowFilter } from "./filter/TimeWindowFilter"; import { IFeatureFilter } from "./filter/FeatureFilter"; -import { RequirementType } from "./model"; +import { FeatureFlag, RequirementType, VariantDefinition } from "./model"; import { IFeatureFlagProvider } from "./featureProvider"; import { TargetingFilter } from "./filter/TargetingFilter"; +import { Variant } from "./variant/Variant"; +import { IFeatureManager } from "./IFeatureManager"; +import { IVariantFeatureManager } from "./variant/IVariantFeatureManager"; +import { ITargetingContext } from "./common/ITargetingContext"; +import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator"; -export class FeatureManager { +export class FeatureManager implements IFeatureManager, IVariantFeatureManager { #provider: IFeatureFlagProvider; #featureFilters: Map = new Map(); @@ -39,6 +44,116 @@ export class FeatureManager { // 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. validateFeatureFlagFormat(featureFlag); + // TODO: status override if specified in allocated variant. Should extract the common logic between isEnabled and getVariant. + return this.#isEnabled(featureFlag, context); + } + + async getVariant(featureName: string, context?: ITargetingContext): Promise { + const featureFlag = await this.#provider.getFeatureFlag(featureName); + if (featureFlag === undefined) { + return undefined; + } + + const enabled = await this.#isEnabled(featureFlag); + + // Determine Variant + let variantDef: VariantDefinition | undefined; + let reason: VariantAssignmentReason = VariantAssignmentReason.None; + + // featureFlag.variant not empty + if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { + if (!enabled) { + // not enabled, assign default if specified + if (featureFlag.allocation?.default_when_disabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); + reason = VariantAssignmentReason.DefaultWhenDisabled; + } else { + // no default specified + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenDisabled; + } + } else { + // enabled, assign based on allocation + if (context !== undefined && featureFlag.allocation !== undefined) { + const result = await this.#assignVariant(featureFlag, context); + variantDef = result.variant; + reason = result.reason; + } + + // allocation failed, assign default if specified + if (variantDef === undefined && reason === VariantAssignmentReason.None) { + if (featureFlag.allocation?.default_when_enabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); + reason = VariantAssignmentReason.DefaultWhenEnabled; + } + } + } + } + + // TODO: send telemetry for variant assignment reason in the future. + console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`); + + if (variantDef?.configuration_reference !== undefined) { + console.warn("Configuration reference is not supported yet."); + } + + return variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; + } + + async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise<{ + variant: VariantDefinition | undefined; + reason: VariantAssignmentReason; + }> { + // user allocation + if (featureFlag.allocation?.user !== undefined) { + for (const userAllocation of featureFlag.allocation.user) { + if (isTargetedUser(context.userId, userAllocation.users)) { + const variant = featureFlag.variants?.find(v => v.name == userAllocation.variant); + if (variant !== undefined) { + return { variant, reason: VariantAssignmentReason.User }; + } else { + console.warn(`Variant ${userAllocation.variant} not found for feature ${featureFlag.id}.`); + } + } + } + } + + // group allocation + if (featureFlag.allocation?.group !== undefined) { + for (const groupAllocation of featureFlag.allocation.group) { + if (isTargetedGroup(context.groups, groupAllocation.groups)) { + const variant = featureFlag.variants?.find(v => v.name == groupAllocation.variant); + if (variant !== undefined) { + return { variant, reason: VariantAssignmentReason.Group }; + } else { + console.warn(`Variant ${groupAllocation.variant} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; + } + } + } + } + + // percentile allocation + if (featureFlag.allocation?.percentile !== undefined) { + for (const percentileAllocation of featureFlag.allocation.percentile) { + const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`; + if (isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) { + const variant = featureFlag.variants?.find(v => v.name == percentileAllocation.variant); + if (variant !== undefined) { + return { variant, reason: VariantAssignmentReason.Percentile }; + } else { + console.warn(`Variant ${percentileAllocation.variant} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; + } + } + } + } + + return { variant: undefined, reason: VariantAssignmentReason.None }; + } + + + async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { if (featureFlag.enabled !== true) { // If the feature is not explicitly enabled, then it is disabled by default. return false; @@ -61,7 +176,7 @@ export class FeatureManager { for (const clientFilter of clientFilters) { const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); - const contextWithFeatureName = { featureName, parameters: clientFilter.parameters }; + const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; if (matchedFeatureFilter === undefined) { console.warn(`Feature filter ${clientFilter.name} is not found.`); return false; @@ -86,3 +201,35 @@ function validateFeatureFlagFormat(featureFlag: any): void { throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); } } + +enum VariantAssignmentReason { + /** + * Variant allocation did not happen. No variant is assigned. + */ + None, + + /** + * The default variant is assigned when a feature flag is disabled. + */ + DefaultWhenDisabled, + + /** + * The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled. + */ + DefaultWhenEnabled, + + /** + * The variant is assigned because of the user allocation when a feature flag is enabled. + */ + User, + + /** + * The variant is assigned because of the group allocation when a feature flag is enabled. + */ + Group, + + /** + * The variant is assigned because of the percentile allocation when a feature flag is enabled. + */ + Percentile +} diff --git a/src/filter/TargetingFilter.ts b/src/filter/TargetingFilter.ts index 1f55901..276ebaa 100644 --- a/src/filter/TargetingFilter.ts +++ b/src/filter/TargetingFilter.ts @@ -2,7 +2,8 @@ // Licensed under the MIT license. import { IFeatureFilter } from "./FeatureFilter"; -import { createHash } from "crypto"; +import { isTargetedPercentile } from "../common/targetingEvaluator"; +import { ITargetingContext } from "../common/ITargetingContext"; type TargetingFilterParameters = { Audience: { @@ -24,15 +25,10 @@ type TargetingFilterEvaluationContext = { parameters: TargetingFilterParameters; } -type TargetingFilterAppContext = { - userId?: string; - groups?: string[]; -} - export class TargetingFilter implements IFeatureFilter { name: string = "Microsoft.Targeting"; - evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean { + evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): boolean { const { featureName, parameters } = context; TargetingFilter.#validateParameters(parameters); @@ -70,9 +66,8 @@ export class TargetingFilter implements IFeatureFilter { parameters.Audience.Groups !== undefined) { for (const group of parameters.Audience.Groups) { if (appContext.groups.includes(group.Name)) { - const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name); - const rolloutPercentage = group.RolloutPercentage; - if (TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) { + const hint = `${featureName}\n${group.Name}`; + if (isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { return true; } } @@ -80,18 +75,8 @@ export class TargetingFilter implements IFeatureFilter { } // check if the user is being targeted by a default rollout percentage - const defaultContextId = constructAudienceContextId(featureName, appContext?.userId); - return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage); - } - - static #isTargeted(audienceContextId: string, rolloutPercentage: number): boolean { - if (rolloutPercentage === 100) { - return true; - } - // Cryptographic hashing algorithms ensure adequate entropy across hash values. - const contextMarker = stringToUint32(audienceContextId); - const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100; - return contextPercentage < rolloutPercentage; + const hint = featureName; + return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); } static #validateParameters(parameters: TargetingFilterParameters): void { @@ -108,36 +93,3 @@ export class TargetingFilter implements IFeatureFilter { } } } - -/** - * Constructs the context id for the audience. - * The context id is used to determine if the user is part of the audience for a feature. - * If groupName is provided, the context id is constructed as follows: - * userId + "\n" + featureName + "\n" + groupName - * Otherwise, the context id is constructed as follows: - * userId + "\n" + featureName - * - * @param featureName name of the feature - * @param userId userId from app context - * @param groupName group name from app context - * @returns a string that represents the context id for the audience - */ -function constructAudienceContextId(featureName: string, userId: string | undefined, groupName?: string) { - let contextId = `${userId ?? ""}\n${featureName}`; - if (groupName !== undefined) { - contextId += `\n${groupName}`; - } - return contextId -} - -function stringToUint32(str: string): number { - // Create a SHA-256 hash of the string - const hash = createHash("sha256").update(str).digest(); - - // Get the first 4 bytes of the hash - const first4Bytes = hash.subarray(0, 4); - - // Convert the 4 bytes to a uint32 with little-endian encoding - const uint32 = first4Bytes.readUInt32LE(0); - return uint32; -} diff --git a/src/model.ts b/src/model.ts index fc6fa19..b707a5a 100644 --- a/src/model.ts +++ b/src/model.ts @@ -31,7 +31,7 @@ export interface FeatureFlag { /** * The list of variants defined for this feature. A variant represents a configuration value of a feature flag that can be a string, a number, a boolean, or a JSON object. */ - variants?: Variant[]; + variants?: VariantDefinition[]; /** * Determines how variants should be allocated for the feature to various users. */ @@ -69,7 +69,7 @@ interface ClientFilter { parameters?: Record; } -interface Variant { +export interface VariantDefinition { /** * The name used to refer to a feature variant. */ diff --git a/src/variant/IVariantFeatureManager.ts b/src/variant/IVariantFeatureManager.ts new file mode 100644 index 0000000..bd1a4d3 --- /dev/null +++ b/src/variant/IVariantFeatureManager.ts @@ -0,0 +1,6 @@ +import { ITargetingContext } from "../common/ITargetingContext"; +import { Variant } from "./Variant"; + +export interface IVariantFeatureManager { + getVariant(featureName: string, context: ITargetingContext): Promise; +} diff --git a/src/variant/Variant.ts b/src/variant/Variant.ts new file mode 100644 index 0000000..2daf288 --- /dev/null +++ b/src/variant/Variant.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export class Variant { + constructor( + public name: string, + public configuration: unknown + ){} +} From e51b9598f203ad80ca1db66710d69a59371c570a Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 31 Jul 2024 12:34:42 +0800 Subject: [PATCH 2/9] add test cases --- src/common/targetingEvaluator.ts | 10 + src/featureManager.ts | 10 + test/sampleFeatureFlags.ts | 536 +++++++++++++++++++++++++++++++ test/variant.test.ts | 85 +++++ 4 files changed, 641 insertions(+) create mode 100644 test/sampleFeatureFlags.ts create mode 100644 test/variant.test.ts diff --git a/src/common/targetingEvaluator.ts b/src/common/targetingEvaluator.ts index bc04b61..fc1fe49 100644 --- a/src/common/targetingEvaluator.ts +++ b/src/common/targetingEvaluator.ts @@ -13,6 +13,16 @@ import { createHash } from "crypto"; * @returns true if the user is part of the audience, false otherwise */ export function isTargetedPercentile(userId: string | undefined, hint: string, from: number, to: number): boolean { + if (from < 0 || from > 100) { + throw new Error("The 'from' value must be between 0 and 100."); + } + if (to <= 0 || to > 100) { + throw new Error("The 'to' value must be between 0 and 100."); + } + if (from > to) { + throw new Error("The 'from' value cannot be larger than the 'to' value."); + } + const audienceContextId = constructAudienceContextId(userId, hint); // Cryptographic hashing algorithms ensure adequate entropy across hash values. diff --git a/src/featureManager.ts b/src/featureManager.ts index c0c6c90..59c1641 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -196,10 +196,20 @@ interface FeatureManagerOptions { customFilters?: IFeatureFilter[]; } +/** + * Validates the format of the feature flag definition. + * + * FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation. + * Thus the properties are not guaranteed to have the expected types. + * + * @param featureFlag The feature flag definition to validate. + */ function validateFeatureFlagFormat(featureFlag: any): void { if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") { throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`); } + // TODO: add more validations. + // TODO: should be moved to the feature flag provider. } enum VariantAssignmentReason { diff --git a/test/sampleFeatureFlags.ts b/test/sampleFeatureFlags.ts new file mode 100644 index 0000000..f99ace0 --- /dev/null +++ b/test/sampleFeatureFlags.ts @@ -0,0 +1,536 @@ +export enum Features { + VariantFeatureDefaultDisabled = "VariantFeatureDefaultDisabled", + VariantFeatureDefaultEnabled = "VariantFeatureDefaultEnabled", + VariantFeaturePercentileOn = "VariantFeaturePercentileOn", + VariantFeaturePercentileOff = "VariantFeaturePercentileOff", + VariantFeatureAlwaysOff = "VariantFeatureAlwaysOff", + VariantFeatureUser = "VariantFeatureUser", + VariantFeatureGroup = "VariantFeatureGroup", + VariantFeatureNoVariants = "VariantFeatureNoVariants", + VariantFeatureNoAllocation = "VariantFeatureNoAllocation", + VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation", + VariantFeatureBothConfigurations = "VariantFeatureBothConfigurations", + VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride", + VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo", + VariantImplementationFeature = "VariantImplementationFeature", +} + +export const featureFlagsConfigurationObject = { + "feature_management": { + "feature_flags": [ + { + "id": "OnTestFeature", + "enabled": true + }, + { + "id": "OffTestFeature", + "enabled": false + }, + { + "id": "ConditionalFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Test", + "parameters": { + "P1": "V1" + } + } + ] + } + }, + { + "id": "ContextualFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "ContextualTest", + "parameters": { + "AllowedAccounts": [ + "abc" + ] + } + } + ] + } + }, + { + "id": "AnyFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "Any", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "AllFilterFeature", + "enabled": true, + "conditions": { + "requirement_type": "All", + "client_filters": [ + { + "name": "Test", + "parameters": { + "Id": "1" + } + }, + { + "name": "Test", + "parameters": { + "Id": "2" + } + } + ] + } + }, + { + "id": "FeatureUsesFiltersWithDuplicatedAlias", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "DuplicatedFilterName" + }, + { + "name": "Percentage", + "parameters": { + "Value": 100 + } + } + ] + } + }, + { + "id": "TargetingTestFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20 + } + } + } + ] + } + }, + { + "id": "TargetingTestFeatureWithExclusion", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20, + "Exclusion": { + "Users": [ + "Jeff" + ], + "Groups": [ + "Ring0", + "Ring2" + ] + } + } + } + } + ] + } + }, + { + "id": "CustomFilterFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "CustomTargetingFilter", + "parameters": { + "Audience": { + "Users": [ + "Jeff" + ] + } + } + } + ] + } + }, + { + "id": "VariantFeaturePercentileOn", + "enabled": true, + "variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big", + "status_override": "Disabled" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": 1234 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeaturePercentileOff", + "enabled": true, + "variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 50 + } + ], + "seed": 12345 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOff", + "enabled": false, + "variants": [ + { + "name": "Big", + "configuration_reference": "ShoppingCart:Big" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Big", + "from": 0, + "to": 100 + } + ], + "seed": 12345 + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultDisabled", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_disabled": "Small" + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureDefaultEnabled", + "enabled": true, + "variants": [ + { + "name": "Medium", + "configuration_value": { + "Size": "450px", + "Color": "Purple" + } + }, + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_enabled": "Medium", + "user": [ + { + "variant": "Small", + "users": [ + "Jeff" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureUser", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureGroup", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "group": [ + { + "variant": "Small", + "groups": [ + "Group1" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoVariants", + "enabled": true, + "variants": [], + "allocation": { + "user": [ + { + "variant": "Small", + "users": [ + "Marsha" + ] + } + ] + }, + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureNoAllocation", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureAlwaysOffNoAllocation", + "enabled": false, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "telemetry": { + "enabled": true + } + }, + { + "id": "VariantFeatureBothConfigurations", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "600px", + "configuration_reference": "ShoppingCart:Small" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidStatusOverride", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px", + "status_override": "InvalidValue" + } + ], + "allocation": { + "default_when_enabled": "Small" + } + }, + { + "id": "VariantFeatureInvalidFromTo", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "percentile": [ + { + "variant": "Small", + "from": "Invalid", + "to": "Invalid" + } + ] + } + }, + { + "id": "VariantImplementationFeature", + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Targeting", + "parameters": { + "Audience": { + "Users": [ + "UserOmega", + "UserSigma", + "UserBeta" + ] + } + } + } + ] + }, + "variants": [ + { + "name": "AlgorithmBeta" + }, + { + "name": "Sigma", + "configuration_value": "AlgorithmSigma" + }, + { + "name": "Omega" + } + ], + "allocation": { + "user": [ + { + "variant": "AlgorithmBeta", + "users": [ + "UserBeta" + ] + }, + { + "variant": "Omega", + "users": [ + "UserOmega" + ] + }, + { + "variant": "Sigma", + "users": [ + "UserSigma" + ] + } + ] + } + }, + { + "id": "OnTelemetryTestFeature", + "enabled": true, + "telemetry": { + "enabled": true, + "metadata": { + "Tags.Tag1": "Tag1Value", + "Tags.Tag2": "Tag2Value", + "Etag": "EtagValue", + "Label": "LabelValue" + } + } + }, + { + "id": "OffTelemetryTestFeature", + "enabled": false, + "telemetry": { + "enabled": true + } + } + ] + } +}; + diff --git a/test/variant.test.ts b/test/variant.test.ts new file mode 100644 index 0000000..21e0896 --- /dev/null +++ b/test/variant.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "./exportedApi"; +import { Features, featureFlagsConfigurationObject } from "./sampleFeatureFlags"; + + +describe("feature variant", () => { + + let featureManager: FeatureManager; + + before(() => { + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); + featureManager = new FeatureManager(provider); + }); + + describe("valid scenarios", () => { + const context = { userId: "Marsha", groups: ["Group1"] }; + + it("Test StatusOverride and Percentile with Seed", async () => { }); + + it("default allocation with disabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultDisabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("default allocation with enabled feature", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureDefaultEnabled, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Medium"); + expect(variant?.configuration).deep.eq({ Size: "450px", Color: "Purple" }); + }); + + + it("user allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureUser, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + it("group allocation", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureGroup, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + expect(variant?.configuration).eq("300px"); + }); + + }); + + describe("invalid scenarios", () => { + const context = { userId: "Jeff" }; + + it("return undefined when no variants are specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoVariants, context); + expect(variant).to.be.undefined; + }); + + it("return undefined when no allocation is specified", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureNoAllocation, context); + expect(variant).to.be.undefined; + }); + + it("only support configuration value", async () => { + const variant = await featureManager.getVariant(Features.VariantFeatureBothConfigurations, context); + expect(variant).not.to.be.undefined; + expect(variant?.configuration).eq("600px"); + }); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid StatusOverride value"); + + // requires IFeatureFlagProvider to throw an exception on validation + it("throw exception for invalid doubles From and To in the Percentile section"); + + }); + +}); From 3ed3d7533a8d3e12636f599fad272f3d730a81e5 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 31 Jul 2024 14:20:24 +0800 Subject: [PATCH 3/9] refactor isEnabled to support status_override --- src/featureManager.ts | 149 +++++++++++++++++++++++++----------------- test/variant.test.ts | 16 ++++- 2 files changed, 102 insertions(+), 63 deletions(-) diff --git a/src/featureManager.ts b/src/featureManager.ts index 59c1641..eae9299 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -35,69 +35,13 @@ export class FeatureManager implements IFeatureManager, IVariantFeatureManager { // If multiple feature flags are found, the first one takes precedence. async isEnabled(featureName: string, context?: unknown): Promise { - const featureFlag = await this.#provider.getFeatureFlag(featureName); - if (featureFlag === undefined) { - // If the feature is not found, then it is disabled. - return false; - } - - // 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. - validateFeatureFlagFormat(featureFlag); - - // TODO: status override if specified in allocated variant. Should extract the common logic between isEnabled and getVariant. - return this.#isEnabled(featureFlag, context); + const result = await this.#evaluateFeature(featureName, context); + return result.enabled; } async getVariant(featureName: string, context?: ITargetingContext): Promise { - const featureFlag = await this.#provider.getFeatureFlag(featureName); - if (featureFlag === undefined) { - return undefined; - } - - const enabled = await this.#isEnabled(featureFlag); - - // Determine Variant - let variantDef: VariantDefinition | undefined; - let reason: VariantAssignmentReason = VariantAssignmentReason.None; - - // featureFlag.variant not empty - if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { - if (!enabled) { - // not enabled, assign default if specified - if (featureFlag.allocation?.default_when_disabled !== undefined) { - variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); - reason = VariantAssignmentReason.DefaultWhenDisabled; - } else { - // no default specified - variantDef = undefined; - reason = VariantAssignmentReason.DefaultWhenDisabled; - } - } else { - // enabled, assign based on allocation - if (context !== undefined && featureFlag.allocation !== undefined) { - const result = await this.#assignVariant(featureFlag, context); - variantDef = result.variant; - reason = result.reason; - } - - // allocation failed, assign default if specified - if (variantDef === undefined && reason === VariantAssignmentReason.None) { - if (featureFlag.allocation?.default_when_enabled !== undefined) { - variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); - reason = VariantAssignmentReason.DefaultWhenEnabled; - } - } - } - } - - // TODO: send telemetry for variant assignment reason in the future. - console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`); - - if (variantDef?.configuration_reference !== undefined) { - console.warn("Configuration reference is not supported yet."); - } - - return variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; + const result = await this.#evaluateFeature(featureName, context); + return result.variant; } async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise<{ @@ -152,7 +96,6 @@ export class FeatureManager implements IFeatureManager, IVariantFeatureManager { return { variant: undefined, reason: VariantAssignmentReason.None }; } - async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { if (featureFlag.enabled !== true) { // If the feature is not explicitly enabled, then it is disabled by default. @@ -190,6 +133,76 @@ export class FeatureManager implements IFeatureManager, IVariantFeatureManager { return !shortCircuitEvaluationResult; } + async #evaluateFeature(featureName: string, context: unknown): Promise { + const featureFlag = await this.#provider.getFeatureFlag(featureName); + const result = new EvaluationResult(featureFlag); + + if (featureFlag === undefined) { + return result; + } + + // 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. + // TODO: move to the feature flag provider implementation. + validateFeatureFlagFormat(featureFlag); + + // Evaluate if the feature is enabled. + result.enabled = await this.#isEnabled(featureFlag, context); + + // Determine Variant + let variantDef: VariantDefinition | undefined; + let reason: VariantAssignmentReason = VariantAssignmentReason.None; + + // featureFlag.variant not empty + if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) { + if (!result.enabled) { + // not enabled, assign default if specified + if (featureFlag.allocation?.default_when_disabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled); + reason = VariantAssignmentReason.DefaultWhenDisabled; + } else { + // no default specified + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenDisabled; + } + } else { + // enabled, assign based on allocation + if (context !== undefined && featureFlag.allocation !== undefined) { + const variantAndReason = await this.#assignVariant(featureFlag, context as ITargetingContext); + variantDef = variantAndReason.variant; + reason = variantAndReason.reason; + } + + // allocation failed, assign default if specified + if (variantDef === undefined && reason === VariantAssignmentReason.None) { + if (featureFlag.allocation?.default_when_enabled !== undefined) { + variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); + reason = VariantAssignmentReason.DefaultWhenEnabled; + } + } + } + } + + // TODO: send telemetry for variant assignment reason in the future. + console.log(`Variant assignment for feature ${featureName}: ${variantDef?.name ?? "default"} (${reason})`); + + if (variantDef?.configuration_reference !== undefined) { + console.warn("Configuration reference is not supported yet."); + } + + result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined; + result.variantAssignmentReason = reason; + + // Status override for isEnabled + if (variantDef !== undefined && featureFlag.enabled) { + if (variantDef.status_override === "Enabled") { + result.enabled = true; + } else if (variantDef.status_override === "Disabled") { + result.enabled = false; + } + } + + return result; + } } interface FeatureManagerOptions { @@ -243,3 +256,17 @@ enum VariantAssignmentReason { */ Percentile } + +class EvaluationResult { + constructor( + // feature flag definition + public readonly feature: FeatureFlag | undefined, + + // enabled state + public enabled: boolean = false, + + // variant assignment + public variant: Variant | undefined = undefined, + public variantAssignmentReason: VariantAssignmentReason = VariantAssignmentReason.None + ) { } +} diff --git a/test/variant.test.ts b/test/variant.test.ts index 21e0896..758d741 100644 --- a/test/variant.test.ts +++ b/test/variant.test.ts @@ -22,8 +22,6 @@ describe("feature variant", () => { describe("valid scenarios", () => { const context = { userId: "Marsha", groups: ["Group1"] }; - it("Test StatusOverride and Percentile with Seed", async () => { }); - it("default allocation with disabled feature", async () => { const variant = await featureManager.getVariant(Features.VariantFeatureDefaultDisabled, context); expect(variant).not.to.be.undefined; @@ -53,6 +51,20 @@ describe("feature variant", () => { expect(variant?.configuration).eq("300px"); }); + it("percentile allocation with seed", async () => { + const variant = await featureManager.getVariant(Features.VariantFeaturePercentileOn, context); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Big"); + + const variant2 = await featureManager.getVariant(Features.VariantFeaturePercentileOff, context); + expect(variant2).to.be.undefined; + }); + + it("overwrite enabled status", async () => { + const enabledStatus = await featureManager.isEnabled(Features.VariantFeaturePercentileOn, context); + expect(enabledStatus).to.be.false; // featureFlag.enabled = true, overridden to false by variant `Big`. + }); + }); describe("invalid scenarios", () => { From 71fe5f3643e18c8233f9a92b9edcf9b1c97ed36b Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 6 Aug 2024 10:35:16 +0800 Subject: [PATCH 4/9] add missing return statement --- src/featureManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/featureManager.ts b/src/featureManager.ts index eae9299..4c0e163 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -57,6 +57,7 @@ export class FeatureManager implements IFeatureManager, IVariantFeatureManager { return { variant, reason: VariantAssignmentReason.User }; } else { console.warn(`Variant ${userAllocation.variant} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; } } } From bf5b0e1536ebe071ae1f4fab5c829c2354a9588d Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 6 Aug 2024 10:35:44 +0800 Subject: [PATCH 5/9] address comments: simplify code --- src/common/targetingEvaluator.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/common/targetingEvaluator.ts b/src/common/targetingEvaluator.ts index fc1fe49..e977cc7 100644 --- a/src/common/targetingEvaluator.ts +++ b/src/common/targetingEvaluator.ts @@ -49,13 +49,7 @@ export function isTargetedGroup(sourceGroups: string[] | undefined, targetedGrou return false; } - for (const group of sourceGroups) { - if (targetedGroups.includes(group)) { - return true; - } - } - - return false; + return sourceGroups.some(group => targetedGroups.includes(group)); } /** From a7f0472698c30ee5e3b6769b244af12ecec91273 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Tue, 6 Aug 2024 10:59:40 +0800 Subject: [PATCH 6/9] fix range validation: align with .NET --- src/common/targetingEvaluator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/targetingEvaluator.ts b/src/common/targetingEvaluator.ts index e977cc7..a4fa41a 100644 --- a/src/common/targetingEvaluator.ts +++ b/src/common/targetingEvaluator.ts @@ -16,7 +16,7 @@ export function isTargetedPercentile(userId: string | undefined, hint: string, f if (from < 0 || from > 100) { throw new Error("The 'from' value must be between 0 and 100."); } - if (to <= 0 || to > 100) { + if (to < 0 || to > 100) { throw new Error("The 'to' value must be between 0 and 100."); } if (from > to) { From ca66f778528e8aa36f91d9bc1e7e0783f3184193 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 7 Aug 2024 10:47:26 +0800 Subject: [PATCH 7/9] use a single interface for the public APIs --- src/IFeatureManager.ts | 19 +++++++++++++++++++ src/featureManager.ts | 3 +-- src/variant/IVariantFeatureManager.ts | 6 ------ 3 files changed, 20 insertions(+), 8 deletions(-) delete mode 100644 src/variant/IVariantFeatureManager.ts diff --git a/src/IFeatureManager.ts b/src/IFeatureManager.ts index 9260f73..f7975f1 100644 --- a/src/IFeatureManager.ts +++ b/src/IFeatureManager.ts @@ -1,7 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { ITargetingContext } from "./common/ITargetingContext"; +import { Variant } from "./variant/Variant"; + export interface IFeatureManager { + /** + * Get the list of feature names. + */ listFeatureNames(): Promise; + + /** + * Check if a feature is enabled. + * @param featureName name of the feature. + * @param context an object providing information that can be used to evaluate whether a feature should be on or off. + */ isEnabled(featureName: string, context?: unknown): Promise; + + /** + * Get the allocated variant of a feature given the targeting context. + * @param featureName name of the feature. + * @param context a targeting context object used to evaluate which variant the user will be assigned. + */ + getVariant(featureName: string, context: ITargetingContext): Promise; } diff --git a/src/featureManager.ts b/src/featureManager.ts index 4c0e163..5214da5 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -8,11 +8,10 @@ import { IFeatureFlagProvider } from "./featureProvider"; import { TargetingFilter } from "./filter/TargetingFilter"; import { Variant } from "./variant/Variant"; import { IFeatureManager } from "./IFeatureManager"; -import { IVariantFeatureManager } from "./variant/IVariantFeatureManager"; import { ITargetingContext } from "./common/ITargetingContext"; import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator"; -export class FeatureManager implements IFeatureManager, IVariantFeatureManager { +export class FeatureManager implements IFeatureManager { #provider: IFeatureFlagProvider; #featureFilters: Map = new Map(); diff --git a/src/variant/IVariantFeatureManager.ts b/src/variant/IVariantFeatureManager.ts deleted file mode 100644 index bd1a4d3..0000000 --- a/src/variant/IVariantFeatureManager.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ITargetingContext } from "../common/ITargetingContext"; -import { Variant } from "./Variant"; - -export interface IVariantFeatureManager { - getVariant(featureName: string, context: ITargetingContext): Promise; -} From 63cdd43fe7ecca650e39248e2f8d080c6b7a939d Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Thu, 8 Aug 2024 10:02:53 +0800 Subject: [PATCH 8/9] extract common code as getVariantAssignment --- src/featureManager.ts | 52 ++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/featureManager.ts b/src/featureManager.ts index 5214da5..9e91dd7 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -43,21 +43,12 @@ export class FeatureManager implements IFeatureManager { return result.variant; } - async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise<{ - variant: VariantDefinition | undefined; - reason: VariantAssignmentReason; - }> { + async #assignVariant(featureFlag: FeatureFlag, context: ITargetingContext): Promise { // user allocation if (featureFlag.allocation?.user !== undefined) { for (const userAllocation of featureFlag.allocation.user) { if (isTargetedUser(context.userId, userAllocation.users)) { - const variant = featureFlag.variants?.find(v => v.name == userAllocation.variant); - if (variant !== undefined) { - return { variant, reason: VariantAssignmentReason.User }; - } else { - console.warn(`Variant ${userAllocation.variant} not found for feature ${featureFlag.id}.`); - return { variant: undefined, reason: VariantAssignmentReason.None }; - } + return getVariantAssignment(featureFlag, userAllocation.variant, VariantAssignmentReason.User); } } } @@ -66,13 +57,7 @@ export class FeatureManager implements IFeatureManager { if (featureFlag.allocation?.group !== undefined) { for (const groupAllocation of featureFlag.allocation.group) { if (isTargetedGroup(context.groups, groupAllocation.groups)) { - const variant = featureFlag.variants?.find(v => v.name == groupAllocation.variant); - if (variant !== undefined) { - return { variant, reason: VariantAssignmentReason.Group }; - } else { - console.warn(`Variant ${groupAllocation.variant} not found for feature ${featureFlag.id}.`); - return { variant: undefined, reason: VariantAssignmentReason.None }; - } + return getVariantAssignment(featureFlag, groupAllocation.variant, VariantAssignmentReason.Group); } } } @@ -82,13 +67,7 @@ export class FeatureManager implements IFeatureManager { for (const percentileAllocation of featureFlag.allocation.percentile) { const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`; if (isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) { - const variant = featureFlag.variants?.find(v => v.name == percentileAllocation.variant); - if (variant !== undefined) { - return { variant, reason: VariantAssignmentReason.Percentile }; - } else { - console.warn(`Variant ${percentileAllocation.variant} not found for feature ${featureFlag.id}.`); - return { variant: undefined, reason: VariantAssignmentReason.None }; - } + return getVariantAssignment(featureFlag, percentileAllocation.variant, VariantAssignmentReason.Percentile); } } } @@ -225,6 +204,29 @@ function validateFeatureFlagFormat(featureFlag: any): void { // TODO: should be moved to the feature flag provider. } +/** + * Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None. + * + * @param featureFlag feature flag definition + * @param variantName variant name + * @param reason variant assignment reason + * @returns variant assignment containing the variant definition and the reason + */ +function getVariantAssignment(featureFlag: FeatureFlag, variantName: string, reason: VariantAssignmentReason): VariantAssignment { + const variant = featureFlag.variants?.find(v => v.name == variantName); + if (variant !== undefined) { + return { variant, reason }; + } else { + console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`); + return { variant: undefined, reason: VariantAssignmentReason.None }; + } +} + +type VariantAssignment = { + variant: VariantDefinition | undefined; + reason: VariantAssignmentReason; +}; + enum VariantAssignmentReason { /** * Variant allocation did not happen. No variant is assigned. From 422b5629d559a3eb8c1e95630ce691a68e16de2d Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Wed, 14 Aug 2024 17:23:35 +0800 Subject: [PATCH 9/9] address comments --- src/common/targetingEvaluator.ts | 4 ++-- src/featureManager.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/common/targetingEvaluator.ts b/src/common/targetingEvaluator.ts index a4fa41a..a048f32 100644 --- a/src/common/targetingEvaluator.ts +++ b/src/common/targetingEvaluator.ts @@ -80,8 +80,8 @@ function constructAudienceContextId(userId: string | undefined, hint: string): s /** * Converts a string to a uint32 in little-endian encoding. - * @param str The string to convert. - * @returns The uint32 value. + * @param str the string to convert. + * @returns a uint32 value. */ function stringToUint32(str: string): number { // Create a SHA-256 hash of the string diff --git a/src/featureManager.ts b/src/featureManager.ts index 9e91dd7..395ed78 100644 --- a/src/featureManager.ts +++ b/src/featureManager.ts @@ -156,6 +156,9 @@ export class FeatureManager implements IFeatureManager { if (featureFlag.allocation?.default_when_enabled !== undefined) { variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled); reason = VariantAssignmentReason.DefaultWhenEnabled; + } else { + variantDef = undefined; + reason = VariantAssignmentReason.DefaultWhenEnabled; } } }