Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/feature-management/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/feature-management/src/IFeatureManager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export interface ITargetingContext {
groups?: string[];
}

export type TargetingContextAccessor = () => ITargetingContext;
28 changes: 23 additions & 5 deletions src/feature-management/src/featureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
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<string, IFeatureFilter> = new Map();
#onFeatureEvaluated?: (event: EvaluationResult) => void;
#targetingContextAccessor?: TargetingContextAccessor;

constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) {
this.#provider = provider;
Expand All @@ -27,6 +28,7 @@
}

this.#onFeatureEvaluated = options?.onFeatureEvaluated;
this.#targetingContextAccessor = options?.targetingContextAccessor;
}

async listFeatureNames(): Promise<string[]> {
Expand Down Expand Up @@ -102,11 +104,19 @@
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);

Check failure on line 117 in src/feature-management/src/featureManager.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Trailing spaces not allowed
}
if (clientFilterEvaluationResult === shortCircuitEvaluationResult) {
return shortCircuitEvaluationResult;
}
}
Expand All @@ -130,7 +140,10 @@
// 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
Expand All @@ -151,7 +164,7 @@
}
} 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;
Expand Down Expand Up @@ -202,6 +215,11 @@
* 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 {
Expand Down
6 changes: 1 addition & 5 deletions src/feature-management/src/filter/TargetingFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 &&
Expand Down
20 changes: 16 additions & 4 deletions src/feature-management/test/targetingFilter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,27 @@ 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 = () => ({ userId, 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(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);
});
});
18 changes: 18 additions & 0 deletions src/feature-management/test/variant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,23 @@
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[] = [];

Check failure on line 98 in src/feature-management/test/variant.test.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

'groups' is never reassigned. Use 'const' instead
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;
});
});
Loading