From f7a306a33c1a3cb1eb1bbda0d29109c35aeb86a9 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 6 Feb 2025 15:31:50 +0800 Subject: [PATCH 01/10] support targeting context accessor --- src/feature-management/package-lock.json | 4 +-- src/feature-management/src/IFeatureManager.ts | 2 +- ...argetingContext.ts => targetingContext.ts} | 1 + src/feature-management/src/featureManager.ts | 28 +++++++++++++++---- .../src/filter/TargetingFilter.ts | 6 +--- .../test/targetingFilter.test.ts | 12 -------- 6 files changed, 28 insertions(+), 25 deletions(-) rename src/feature-management/src/common/{ITargetingContext.ts => targetingContext.ts} (71%) diff --git a/src/feature-management/package-lock.json b/src/feature-management/package-lock.json index 74c975d..f8c9a0a 100644 --- a/src/feature-management/package-lock.json +++ b/src/feature-management/package-lock.json @@ -20,7 +20,7 @@ "eslint": "^8.56.0", "mocha": "^10.2.0", "rimraf": "^5.0.5", - "rollup": "^4.9.4", + "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.0", "tslib": "^2.6.2", "typescript": "^5.3.3" @@ -3212,4 +3212,4 @@ } } } -} \ No newline at end of file +} 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/targetingContext.ts similarity index 71% rename from src/feature-management/src/common/ITargetingContext.ts rename to src/feature-management/src/common/targetingContext.ts index 1d5a426..ccc8dfb 100644 --- a/src/feature-management/src/common/ITargetingContext.ts +++ b/src/feature-management/src/common/targetingContext.ts @@ -6,3 +6,4 @@ export interface ITargetingContext { groups?: string[]; } +export type TargetingContextAccessor = () => ITargetingContext; diff --git a/src/feature-management/src/featureManager.ts b/src/feature-management/src/featureManager.ts index a035b5d..9c2581f 100644 --- a/src/feature-management/src/featureManager.ts +++ b/src/feature-management/src/featureManager.ts @@ -8,13 +8,14 @@ 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, TargetingContextAccessor } 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; + #targetingContextAccessor?: TargetingContextAccessor; constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) { this.#provider = provider; @@ -27,6 +28,7 @@ export class FeatureManager implements IFeatureManager { } this.#onFeatureEvaluated = options?.onFeatureEvaluated; + this.#targetingContextAccessor = options?.targetingContextAccessor; } async listFeatureNames(): Promise { @@ -102,11 +104,19 @@ export class FeatureManager implements IFeatureManager { for (const clientFilter of clientFilters) { const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; + let clientFilterEvaluationResult: boolean; if (matchedFeatureFilter === undefined) { console.warn(`Feature filter ${clientFilter.name} is not found.`); - return false; + clientFilterEvaluationResult = false; } - if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) { + else { + let appContext = context; + if (clientFilter.name === "Microsoft.Targeting" && this.#targetingContextAccessor !== undefined) { + appContext = this.#targetingContextAccessor(); + } + clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext); + } + if (clientFilterEvaluationResult === shortCircuitEvaluationResult) { return shortCircuitEvaluationResult; } } @@ -130,7 +140,10 @@ export class FeatureManager implements IFeatureManager { // Evaluate if the feature is enabled. result.enabled = await this.#isEnabled(featureFlag, context); - const targetingContext = context as ITargetingContext; + let targetingContext = context as ITargetingContext; + if (this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor(); + } result.targetingId = targetingContext?.userId; // Determine Variant @@ -151,7 +164,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; @@ -202,6 +215,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?: TargetingContextAccessor; } export class EvaluationResult { diff --git a/src/feature-management/src/filter/TargetingFilter.ts b/src/feature-management/src/filter/TargetingFilter.ts index 2d7220e..e6da2f7 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 } from "../common/targetingContext.js"; type TargetingFilterParameters = { Audience: { @@ -32,10 +32,6 @@ export class TargetingFilter implements IFeatureFilter { const { featureName, parameters } = context; TargetingFilter.#validateParameters(featureName, parameters); - if (appContext === undefined) { - throw new Error("The app context is required for targeting filter."); - } - if (parameters.Audience.Exclusion !== undefined) { // check if the user is in the exclusion list if (appContext?.userId !== undefined && diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index 91fe81b..4e92c42 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -130,16 +130,4 @@ describe("targeting filter", () => { expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), ]); }); - - it("should throw error if app context is not provided", () => { - const dataSource = new Map(); - dataSource.set("feature_management", { - feature_flags: [complexTargetingFeature] - }); - - 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."); - }); }); From aa548186182a3be3f3b253989cd51aa2addb832c Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 6 Feb 2025 16:08:19 +0800 Subject: [PATCH 02/10] add test --- .../test/targetingFilter.test.ts | 24 +++++++++++++++++++ src/feature-management/test/variant.test.ts | 18 ++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index 4e92c42..210972e 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -130,4 +130,28 @@ describe("targeting filter", () => { expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"), ]); }); + + 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 = () => ({ userId, groups }); + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + 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(true); // targeting id will be overridden by the context accessor + 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 ddfd90f..0484bf7 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -90,5 +90,23 @@ describe("feature variant", () => { it("throw exception for invalid doubles From and To in the Percentile section"); }); +}); +describe("variant assignment with targeting context accessor", () => { + it("should assign variant based on targeting context accessor", async () => { + let userId = ""; + let groups: string[] = []; + const testTargetingContextAccessor = () => ({ userId, 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 by the context accessor + expect(variant).to.be.undefined; + }); }); From 7e2652474f19e08b9c77b9736ccc13adaac6f150 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 6 Feb 2025 16:14:59 +0800 Subject: [PATCH 03/10] fix lint --- src/feature-management/src/featureManager.ts | 2 +- src/feature-management/test/variant.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/feature-management/src/featureManager.ts b/src/feature-management/src/featureManager.ts index 9c2581f..1d2d76c 100644 --- a/src/feature-management/src/featureManager.ts +++ b/src/feature-management/src/featureManager.ts @@ -114,7 +114,7 @@ export class FeatureManager implements IFeatureManager { if (clientFilter.name === "Microsoft.Targeting" && this.#targetingContextAccessor !== undefined) { appContext = this.#targetingContextAccessor(); } - clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext); + clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext); } if (clientFilterEvaluationResult === shortCircuitEvaluationResult) { return shortCircuitEvaluationResult; diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index 0484bf7..a610290 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -95,7 +95,7 @@ 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 groups: string[] = []; const testTargetingContextAccessor = () => ({ userId, groups }); const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); From 0c7c1a52f2be5e83c61ad59b29c75c605c12d5cb Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 6 Feb 2025 16:18:26 +0800 Subject: [PATCH 04/10] update --- src/feature-management/test/targetingFilter.test.ts | 2 +- src/feature-management/test/variant.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index 210972e..c6a9e22 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -139,7 +139,7 @@ describe("targeting filter", () => { let userId = ""; let groups: string[] = []; - const testTargetingContextAccessor = () => ({ userId, groups }); + const testTargetingContextAccessor = () => ({ userId: userId, groups: groups }); const provider = new ConfigurationMapFeatureFlagProvider(dataSource); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index a610290..b1b746f 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -95,8 +95,8 @@ describe("feature variant", () => { describe("variant assignment with targeting context accessor", () => { it("should assign variant based on targeting context accessor", async () => { let userId = ""; - const groups: string[] = []; - const testTargetingContextAccessor = () => ({ userId, groups }); + let groups: string[] = []; + const testTargetingContextAccessor = () => ({ userId: userId, groups: groups }); const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); userId = "Marsha"; From f612aadcab819e77c3cb000d377f7e40e2627c28 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 6 Feb 2025 16:22:25 +0800 Subject: [PATCH 05/10] update --- src/feature-management/test/variant.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index b1b746f..31d41f8 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -108,5 +108,9 @@ describe("variant assignment with targeting context accessor", () => { expect(variant).to.be.undefined; variant = await featureManager.getVariant(Features.VariantFeatureUser, {userId: "Marsha"}); // targeting id will be overridden by the context accessor expect(variant).to.be.undefined; + groups = ["Group1"]; + variant = await featureManager.getVariant(Features.VariantFeatureGroup); + expect(variant).not.to.be.undefined; + expect(variant?.name).eq("Small"); }); }); From 730bb2e34646aa89ef84a1415c2e09488d5a9b0c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 10 Feb 2025 18:22:56 +0800 Subject: [PATCH 06/10] export targeting context --- src/feature-management/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/feature-management/src/index.ts b/src/feature-management/src/index.ts index 77d18c5..24eb7a8 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 { TargetingContextAccessor, ITargetingContext } from "./common/targetingContext.js"; export { VERSION } from "./version.js"; From b9bcbc99c53ed4a040e7c0d8f20776b3d6a88c0f Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Mon, 10 Feb 2025 18:36:46 +0800 Subject: [PATCH 07/10] add comments --- .../src/common/targetingContext.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/feature-management/src/common/targetingContext.ts b/src/feature-management/src/common/targetingContext.ts index ccc8dfb..db1d8d4 100644 --- a/src/feature-management/src/common/targetingContext.ts +++ b/src/feature-management/src/common/targetingContext.ts @@ -1,9 +1,21 @@ // 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[]; } +/** + * Type definition for a function that, when invoked, returns the @see ITargetingContext for targeting evaluation. + */ export type TargetingContextAccessor = () => ITargetingContext; From 1d6889ac8b2fa19aad5db2c8af27bc5c5b0f137c Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Sun, 23 Feb 2025 00:32:18 +0800 Subject: [PATCH 08/10] update --- src/feature-management/src/featureManager.ts | 28 +++++++++++-------- .../test/targetingFilter.test.ts | 2 +- src/feature-management/test/variant.test.ts | 5 ++-- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/feature-management/src/featureManager.ts b/src/feature-management/src/featureManager.ts index 1d2d76c..3666642 100644 --- a/src/feature-management/src/featureManager.ts +++ b/src/feature-management/src/featureManager.ts @@ -80,7 +80,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; @@ -110,11 +110,11 @@ export class FeatureManager implements IFeatureManager { clientFilterEvaluationResult = false; } else { - let appContext = context; - if (clientFilter.name === "Microsoft.Targeting" && this.#targetingContextAccessor !== undefined) { - appContext = this.#targetingContextAccessor(); + if (clientFilter.name === "Microsoft.Targeting") { + clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, this.#getTargetingContext(appContext)); + } else { + clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext); } - clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext); } if (clientFilterEvaluationResult === shortCircuitEvaluationResult) { return shortCircuitEvaluationResult; @@ -125,7 +125,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); @@ -138,12 +138,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); - let targetingContext = context as ITargetingContext; - if (this.#targetingContextAccessor !== undefined) { - targetingContext = this.#targetingContextAccessor(); - } + // Get targeting context from the app context or the targeting context accessor + const targetingContext = this.#getTargetingContext(appContext); result.targetingId = targetingContext?.userId; // Determine Variant @@ -202,6 +200,14 @@ export class FeatureManager implements IFeatureManager { return result; } + + #getTargetingContext(context: unknown): ITargetingContext | undefined { + let targetingContext = context as ITargetingContext; + if (targetingContext === undefined && this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor(); + } + return targetingContext; + } } export interface FeatureManagerOptions { diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index c6a9e22..6219c24 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -147,7 +147,7 @@ describe("targeting filter", () => { 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(true); // targeting id will be overridden by the context accessor + 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); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index 31d41f8..e9a9f58 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -106,8 +106,9 @@ describe("variant assignment with targeting context accessor", () => { 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 by the context accessor - 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; From 0e0b3046e0a887d4b510df644d2891d7ff603252 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 6 Mar 2025 13:05:20 +0800 Subject: [PATCH 09/10] update --- .../src/common/targetingContext.ts | 9 ++++- src/feature-management/src/featureManager.ts | 40 ++++++++----------- .../src/filter/TargetingFilter.ts | 36 +++++++++++------ .../src/filter/TimeWindowFilter.ts | 2 +- src/feature-management/src/index.ts | 2 +- .../test/targetingFilter.test.ts | 6 ++- src/feature-management/test/variant.test.ts | 6 ++- 7 files changed, 59 insertions(+), 42 deletions(-) diff --git a/src/feature-management/src/common/targetingContext.ts b/src/feature-management/src/common/targetingContext.ts index db1d8d4..a133f15 100644 --- a/src/feature-management/src/common/targetingContext.ts +++ b/src/feature-management/src/common/targetingContext.ts @@ -16,6 +16,11 @@ export interface ITargetingContext { } /** - * Type definition for a function that, when invoked, returns the @see ITargetingContext for targeting evaluation. + * Provides access to the current targeting context. */ -export type TargetingContextAccessor = () => ITargetingContext; +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 3666642..69fc6cb 100644 --- a/src/feature-management/src/featureManager.ts +++ b/src/feature-management/src/featureManager.ts @@ -8,27 +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, TargetingContextAccessor } from "./common/targetingContext.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; - #targetingContextAccessor?: TargetingContextAccessor; + 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; - this.#targetingContextAccessor = options?.targetingContextAccessor; } async listFeatureNames(): Promise { @@ -104,19 +102,11 @@ export class FeatureManager implements IFeatureManager { for (const clientFilter of clientFilters) { const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name); const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters }; - let clientFilterEvaluationResult: boolean; if (matchedFeatureFilter === undefined) { console.warn(`Feature filter ${clientFilter.name} is not found.`); - clientFilterEvaluationResult = false; - } - else { - if (clientFilter.name === "Microsoft.Targeting") { - clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, this.#getTargetingContext(appContext)); - } else { - clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext); - } + return false; } - if (clientFilterEvaluationResult === shortCircuitEvaluationResult) { + if (await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext) === shortCircuitEvaluationResult) { return shortCircuitEvaluationResult; } } @@ -202,9 +192,11 @@ export class FeatureManager implements IFeatureManager { } #getTargetingContext(context: unknown): ITargetingContext | undefined { - let targetingContext = context as ITargetingContext; - if (targetingContext === undefined && this.#targetingContextAccessor !== undefined) { - targetingContext = this.#targetingContextAccessor(); + let targetingContext: ITargetingContext | undefined = context as ITargetingContext; + if (targetingContext?.userId === undefined && + targetingContext?.groups === undefined && + this.#targetingContextAccessor !== undefined) { + targetingContext = this.#targetingContextAccessor.getTargetingContext(); } return targetingContext; } @@ -225,7 +217,7 @@ export interface FeatureManagerOptions { /** * The accessor function that provides the @see ITargetingContext for targeting evaluation. */ - targetingContextAccessor?: TargetingContextAccessor; + targetingContextAccessor?: ITargetingContextAccessor; } export class EvaluationResult { diff --git a/src/feature-management/src/filter/TargetingFilter.ts b/src/feature-management/src/filter/TargetingFilter.ts index e6da2f7..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/targetingContext.js"; +import { ITargetingContext, ITargetingContextAccessor } from "../common/targetingContext.js"; type TargetingFilterParameters = { Audience: { @@ -26,24 +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); + 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; } } @@ -51,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; } } @@ -72,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 24eb7a8..093d3fd 100644 --- a/src/feature-management/src/index.ts +++ b/src/feature-management/src/index.ts @@ -5,5 +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 { TargetingContextAccessor, ITargetingContext } from "./common/targetingContext.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 6219c24..e5d6176 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -139,7 +139,11 @@ describe("targeting filter", () => { let userId = ""; let groups: string[] = []; - const testTargetingContextAccessor = () => ({ userId: userId, groups: groups }); + const testTargetingContextAccessor = { + getTargetingContext: () => { + return { userId: userId, groups: groups }; + } + } const provider = new ConfigurationMapFeatureFlagProvider(dataSource); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index e9a9f58..3f2b000 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -96,7 +96,11 @@ describe("variant assignment with targeting context accessor", () => { it("should assign variant based on targeting context accessor", async () => { let userId = ""; let groups: string[] = []; - const testTargetingContextAccessor = () => ({ userId: userId, groups: groups }); + const testTargetingContextAccessor = { + getTargetingContext: () => { + return { userId: userId, groups: groups }; + } + } const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); userId = "Marsha"; From 73353fa5edaa6abd8181ea9bbe68448487235716 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 6 Mar 2025 13:07:30 +0800 Subject: [PATCH 10/10] fix lint --- src/feature-management/test/targetingFilter.test.ts | 2 +- src/feature-management/test/variant.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index e5d6176..6da25f6 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -143,7 +143,7 @@ describe("targeting filter", () => { getTargetingContext: () => { return { userId: userId, groups: groups }; } - } + }; const provider = new ConfigurationMapFeatureFlagProvider(dataSource); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index 3f2b000..684d74d 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -100,7 +100,7 @@ describe("variant assignment with targeting context accessor", () => { getTargetingContext: () => { return { userId: userId, groups: groups }; } - } + }; const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject); const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor}); userId = "Marsha";