Skip to content

Support targeting context accessor #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 26, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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.

26 changes: 26 additions & 0 deletions src/feature-management/src/common/targetingContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 30 additions & 14 deletions src/feature-management/src/featureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IFeatureFilter> = new Map();
#onFeatureEvaluated?: (event: EvaluationResult) => void;
readonly #provider: IFeatureFlagProvider;
readonly #featureFilters: Map<string, IFeatureFilter> = 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<string[]> {
Expand Down Expand Up @@ -78,7 +78,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 Down Expand Up @@ -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;
}
}
Expand All @@ -115,7 +115,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 +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
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
36 changes: 22 additions & 14 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, ITargetingContextAccessor } from "../common/targetingContext.js";

type TargetingFilterParameters = {
Audience: {
Expand All @@ -26,48 +26,56 @@ 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<boolean> {
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;
}
}
}
}

// 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;
}
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/feature-management/src/filter/TimeWindowFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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 { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js";
export { VERSION } from "./version.js";
24 changes: 20 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,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);
});
});
27 changes: 27 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,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");
});
});