diff --git a/src/feature-management/src/IFeatureManager.ts b/src/feature-management/src/IFeatureManager.ts index d673dce..f982a6c 100644 --- a/src/feature-management/src/IFeatureManager.ts +++ b/src/feature-management/src/IFeatureManager.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ITargetingContext } from "./common/ITargetingContext"; +import { ITargetingContext } from "./common/targetingContext"; import { Variant } from "./variant/Variant"; export interface IFeatureManager { diff --git a/src/feature-management/src/common/ITargetingContext.ts b/src/feature-management/src/common/ITargetingContext.ts deleted file mode 100644 index 1d5a426..0000000 --- a/src/feature-management/src/common/ITargetingContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export interface ITargetingContext { - userId?: string; - groups?: string[]; -} - diff --git a/src/feature-management/src/common/targetingContext.ts b/src/feature-management/src/common/targetingContext.ts new file mode 100644 index 0000000..a133f15 --- /dev/null +++ b/src/feature-management/src/common/targetingContext.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Contextual information that is required to perform a targeting evaluation. + */ +export interface ITargetingContext { + /** + * The user id that should be considered when evaluating if the context is being targeted. + */ + userId?: string; + /** + * The groups that should be considered when evaluating if the context is being targeted. + */ + groups?: string[]; +} + +/** + * Provides access to the current targeting context. + */ +export interface ITargetingContextAccessor { + /** + * Retrieves the current targeting context. + */ + getTargetingContext: () => ITargetingContext | undefined; +} diff --git a/src/feature-management/src/featureManager.ts b/src/feature-management/src/featureManager.ts index a035b5d..69fc6cb 100644 --- a/src/feature-management/src/featureManager.ts +++ b/src/feature-management/src/featureManager.ts @@ -8,25 +8,25 @@ import { IFeatureFlagProvider } from "./featureProvider.js"; import { TargetingFilter } from "./filter/TargetingFilter.js"; import { Variant } from "./variant/Variant.js"; import { IFeatureManager } from "./IFeatureManager.js"; -import { ITargetingContext } from "./common/ITargetingContext.js"; +import { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js"; import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator.js"; export class FeatureManager implements IFeatureManager { - #provider: IFeatureFlagProvider; - #featureFilters: Map = new Map(); - #onFeatureEvaluated?: (event: EvaluationResult) => void; + readonly #provider: IFeatureFlagProvider; + readonly #featureFilters: Map = new Map(); + readonly #onFeatureEvaluated?: (event: EvaluationResult) => void; + readonly #targetingContextAccessor?: ITargetingContextAccessor; constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { this.#provider = provider; + this.#onFeatureEvaluated = options?.onFeatureEvaluated; + this.#targetingContextAccessor = options?.targetingContextAccessor; - const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()]; - + const builtinFilters = [new TimeWindowFilter(), new TargetingFilter(options?.targetingContextAccessor)]; // If a custom filter shares a name with an existing filter, the custom filter overrides the existing one. for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) { this.#featureFilters.set(filter.name, filter); } - - this.#onFeatureEvaluated = options?.onFeatureEvaluated; } async listFeatureNames(): Promise { @@ -78,7 +78,7 @@ export class FeatureManager implements IFeatureManager { return { variant: undefined, reason: VariantAssignmentReason.None }; } - async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise { + async #isEnabled(featureFlag: FeatureFlag, appContext?: unknown): Promise { if (featureFlag.enabled !== true) { // If the feature is not explicitly enabled, then it is disabled by default. return false; @@ -106,7 +106,7 @@ export class FeatureManager implements IFeatureManager { console.warn(`Feature filter ${clientFilter.name} is not found.`); return false; } - if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { + if (await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext) === shortCircuitEvaluationResult) { return shortCircuitEvaluationResult; } } @@ -115,7 +115,7 @@ export class FeatureManager implements IFeatureManager { return !shortCircuitEvaluationResult; } - async #evaluateFeature(featureName: string, context: unknown): Promise { + async #evaluateFeature(featureName: string, appContext: unknown): Promise { const featureFlag = await this.#provider.getFeatureFlag(featureName); const result = new EvaluationResult(featureFlag); @@ -128,9 +128,10 @@ export class FeatureManager implements IFeatureManager { validateFeatureFlagFormat(featureFlag); // Evaluate if the feature is enabled. - result.enabled = await this.#isEnabled(featureFlag, context); + result.enabled = await this.#isEnabled(featureFlag, appContext); - const targetingContext = context as ITargetingContext; + // Get targeting context from the app context or the targeting context accessor + const targetingContext = this.#getTargetingContext(appContext); result.targetingId = targetingContext?.userId; // Determine Variant @@ -151,7 +152,7 @@ export class FeatureManager implements IFeatureManager { } } else { // enabled, assign based on allocation - if (context !== undefined && featureFlag.allocation !== undefined) { + if (targetingContext !== undefined && featureFlag.allocation !== undefined) { const variantAndReason = await this.#assignVariant(featureFlag, targetingContext); variantDef = variantAndReason.variant; reason = variantAndReason.reason; @@ -189,6 +190,16 @@ export class FeatureManager implements IFeatureManager { return result; } + + #getTargetingContext(context: unknown): ITargetingContext | undefined { + let targetingContext: ITargetingContext | undefined = context as ITargetingContext; + if (targetingContext?.userId === undefined && + targetingContext?.groups === undefined && + this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor.getTargetingContext(); + } + return targetingContext; + } } export interface FeatureManagerOptions { @@ -202,6 +213,11 @@ export interface FeatureManagerOptions { * The callback function is called only when telemetry is enabled for the feature flag. */ onFeatureEvaluated?: (event: EvaluationResult) => void; + + /** + * The accessor function that provides the @see ITargetingContext for targeting evaluation. + */ + targetingContextAccessor?: ITargetingContextAccessor; } export class EvaluationResult { diff --git a/src/feature-management/src/filter/TargetingFilter.ts b/src/feature-management/src/filter/TargetingFilter.ts index 2d7220e..eb4b73d 100644 --- a/src/feature-management/src/filter/TargetingFilter.ts +++ b/src/feature-management/src/filter/TargetingFilter.ts @@ -3,7 +3,7 @@ import { IFeatureFilter } from "./FeatureFilter.js"; import { isTargetedPercentile } from "../common/targetingEvaluator.js"; -import { ITargetingContext } from "../common/ITargetingContext.js"; +import { ITargetingContext, ITargetingContextAccessor } from "../common/targetingContext.js"; type TargetingFilterParameters = { Audience: { @@ -26,28 +26,36 @@ type TargetingFilterEvaluationContext = { } export class TargetingFilter implements IFeatureFilter { - name: string = "Microsoft.Targeting"; + readonly name: string = "Microsoft.Targeting"; + readonly #targetingContextAccessor?: ITargetingContextAccessor; + + constructor(targetingContextAccessor?: ITargetingContextAccessor) { + this.#targetingContextAccessor = targetingContextAccessor; + } async evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): Promise { const { featureName, parameters } = context; TargetingFilter.#validateParameters(featureName, parameters); - if (appContext === undefined) { - throw new Error("The app context is required for targeting filter."); + let targetingContext: ITargetingContext | undefined; + if (appContext?.userId !== undefined || appContext?.groups !== undefined) { + targetingContext = appContext; + } else if (this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor.getTargetingContext(); } if (parameters.Audience.Exclusion !== undefined) { // check if the user is in the exclusion list - if (appContext?.userId !== undefined && + if (targetingContext?.userId !== undefined && parameters.Audience.Exclusion.Users !== undefined && - parameters.Audience.Exclusion.Users.includes(appContext.userId)) { + parameters.Audience.Exclusion.Users.includes(targetingContext.userId)) { return false; } // check if the user is in a group within exclusion list - if (appContext?.groups !== undefined && + if (targetingContext?.groups !== undefined && parameters.Audience.Exclusion.Groups !== undefined) { for (const excludedGroup of parameters.Audience.Exclusion.Groups) { - if (appContext.groups.includes(excludedGroup)) { + if (targetingContext.groups.includes(excludedGroup)) { return false; } } @@ -55,19 +63,19 @@ export class TargetingFilter implements IFeatureFilter { } // check if the user is being targeted directly - if (appContext?.userId !== undefined && + if (targetingContext?.userId !== undefined && parameters.Audience.Users !== undefined && - parameters.Audience.Users.includes(appContext.userId)) { + parameters.Audience.Users.includes(targetingContext.userId)) { return true; } // check if the user is in a group that is being targeted - if (appContext?.groups !== undefined && + if (targetingContext?.groups !== undefined && parameters.Audience.Groups !== undefined) { for (const group of parameters.Audience.Groups) { - if (appContext.groups.includes(group.Name)) { + if (targetingContext.groups.includes(group.Name)) { const hint = `${featureName}\n${group.Name}`; - if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) { + if (await isTargetedPercentile(targetingContext.userId, hint, 0, group.RolloutPercentage)) { return true; } } @@ -76,7 +84,7 @@ export class TargetingFilter implements IFeatureFilter { // check if the user is being targeted by a default rollout percentage const hint = featureName; - return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); + return isTargetedPercentile(targetingContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage); } static #validateParameters(featureName: string, parameters: TargetingFilterParameters): void { diff --git a/src/feature-management/src/filter/TimeWindowFilter.ts b/src/feature-management/src/filter/TimeWindowFilter.ts index 3cd0ead..beb0136 100644 --- a/src/feature-management/src/filter/TimeWindowFilter.ts +++ b/src/feature-management/src/filter/TimeWindowFilter.ts @@ -15,7 +15,7 @@ type TimeWindowFilterEvaluationContext = { } export class TimeWindowFilter implements IFeatureFilter { - name: string = "Microsoft.TimeWindow"; + readonly name: string = "Microsoft.TimeWindow"; evaluate(context: TimeWindowFilterEvaluationContext): boolean { const {featureName, parameters} = context; diff --git a/src/feature-management/src/index.ts b/src/feature-management/src/index.ts index 77d18c5..093d3fd 100644 --- a/src/feature-management/src/index.ts +++ b/src/feature-management/src/index.ts @@ -5,4 +5,5 @@ export { FeatureManager, FeatureManagerOptions, EvaluationResult, VariantAssignm export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js"; export { createFeatureEvaluationEventProperties } from "./telemetry/featureEvaluationEvent.js"; export { IFeatureFilter } from "./filter/FeatureFilter.js"; +export { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js"; export { VERSION } from "./version.js"; diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index 91fe81b..6da25f6 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -131,15 +131,31 @@ describe("targeting filter", () => { ]); }); - it("should throw error if app context is not provided", () => { + it("should evaluate feature with targeting filter with targeting context accessor", async () => { const dataSource = new Map(); dataSource.set("feature_management", { feature_flags: [complexTargetingFeature] }); + let userId = ""; + let groups: string[] = []; + const testTargetingContextAccessor = { + getTargetingContext: () => { + return { userId: userId, groups: groups }; + } + }; const provider = new ConfigurationMapFeatureFlagProvider(dataSource); - const featureManager = new FeatureManager(provider); - - return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter."); + const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); + + userId = "Aiden"; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(false); + userId = "Blossom"; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(true); + expect(await featureManager.isEnabled("ComplexTargeting", {userId: "Aiden"})).to.eq(false); // targeting id will be overridden + userId = "Aiden"; + groups = ["Stage2"]; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(true); + userId = "Chris"; + expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(false); }); }); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index 118fc03..edff9a0 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -90,5 +90,32 @@ describe("feature variant", () => { }); }); +}); +describe("variant assignment with targeting context accessor", () => { + it("should assign variant based on targeting context accessor", async () => { + let userId = ""; + let groups: string[] = []; + const testTargetingContextAccessor = { + getTargetingContext: () => { + return { userId: userId, groups: groups }; + } + }; + const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); + const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); + userId = "Marsha"; + let variant = await featureManager.getVariant(Features.VariantFeatureUser); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + userId = "Jeff"; + variant = await featureManager.getVariant(Features.VariantFeatureUser); + expect(variant).to.be.undefined; + variant = await featureManager.getVariant(Features.VariantFeatureUser, {userId: "Marsha"}); // targeting id will be overridden + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + groups = ["Group1"]; + variant = await featureManager.getVariant(Features.VariantFeatureGroup); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); + }); });