Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
8 changes: 0 additions & 8 deletions src/feature-management/src/common/ITargetingContext.ts

This file was deleted.

21 changes: 21 additions & 0 deletions src/feature-management/src/common/targetingContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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;
40 changes: 32 additions & 8 deletions src/feature-management/src/featureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IFeatureFilter> = new Map();
#onFeatureEvaluated?: (event: EvaluationResult) => void;
#targetingContextAccessor?: TargetingContextAccessor;

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

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

async listFeatureNames(): Promise<string[]> {
Expand Down Expand Up @@ -78,7 +80,7 @@ export class FeatureManager implements IFeatureManager {
return { variant: undefined, reason: VariantAssignmentReason.None };
}

async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise<boolean> {
async #isEnabled(featureFlag: FeatureFlag, appContext?: unknown): Promise<boolean> {
if (featureFlag.enabled !== true) {
// If the feature is not explicitly enabled, then it is disabled by default.
return false;
Expand All @@ -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 {
if (clientFilter.name === "Microsoft.Targeting") {
clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, this.#getTargetingContext(appContext));
} else {
clientFilterEvaluationResult = await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext);
}
}
if (clientFilterEvaluationResult === shortCircuitEvaluationResult) {
return shortCircuitEvaluationResult;
}
}
Expand All @@ -115,7 +125,7 @@ export class FeatureManager implements IFeatureManager {
return !shortCircuitEvaluationResult;
}

async #evaluateFeature(featureName: string, context: unknown): Promise<EvaluationResult> {
async #evaluateFeature(featureName: string, appContext: unknown): Promise<EvaluationResult> {
const featureFlag = await this.#provider.getFeatureFlag(featureName);
const result = new EvaluationResult(featureFlag);

Expand All @@ -128,9 +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);

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
Expand All @@ -151,7 +162,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;
Expand Down Expand Up @@ -189,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 {
Expand All @@ -202,6 +221,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 {
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
1 change: 1 addition & 0 deletions src/feature-management/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
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: 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);
});
});
23 changes: 23 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,28 @@ 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: 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");
});
});