Skip to content

Commit a7694d6

Browse files
Support targeting context accessor (#93)
* support targeting context accessor * add test * fix lint * update * update * export targeting context * add comments * update * update * fix lint
1 parent b65da98 commit a7694d6

File tree

9 files changed

+128
-42
lines changed

9 files changed

+128
-42
lines changed

src/feature-management/src/IFeatureManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { ITargetingContext } from "./common/ITargetingContext";
4+
import { ITargetingContext } from "./common/targetingContext";
55
import { Variant } from "./variant/Variant";
66

77
export interface IFeatureManager {

src/feature-management/src/common/ITargetingContext.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
/**
5+
* Contextual information that is required to perform a targeting evaluation.
6+
*/
7+
export interface ITargetingContext {
8+
/**
9+
* The user id that should be considered when evaluating if the context is being targeted.
10+
*/
11+
userId?: string;
12+
/**
13+
* The groups that should be considered when evaluating if the context is being targeted.
14+
*/
15+
groups?: string[];
16+
}
17+
18+
/**
19+
* Provides access to the current targeting context.
20+
*/
21+
export interface ITargetingContextAccessor {
22+
/**
23+
* Retrieves the current targeting context.
24+
*/
25+
getTargetingContext: () => ITargetingContext | undefined;
26+
}

src/feature-management/src/featureManager.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,25 @@ import { IFeatureFlagProvider } from "./featureProvider.js";
88
import { TargetingFilter } from "./filter/TargetingFilter.js";
99
import { Variant } from "./variant/Variant.js";
1010
import { IFeatureManager } from "./IFeatureManager.js";
11-
import { ITargetingContext } from "./common/ITargetingContext.js";
11+
import { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js";
1212
import { isTargetedGroup, isTargetedPercentile, isTargetedUser } from "./common/targetingEvaluator.js";
1313

1414
export class FeatureManager implements IFeatureManager {
15-
#provider: IFeatureFlagProvider;
16-
#featureFilters: Map<string, IFeatureFilter> = new Map();
17-
#onFeatureEvaluated?: (event: EvaluationResult) => void;
15+
readonly #provider: IFeatureFlagProvider;
16+
readonly #featureFilters: Map<string, IFeatureFilter> = new Map();
17+
readonly #onFeatureEvaluated?: (event: EvaluationResult) => void;
18+
readonly #targetingContextAccessor?: ITargetingContextAccessor;
1819

1920
constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) {
2021
this.#provider = provider;
22+
this.#onFeatureEvaluated = options?.onFeatureEvaluated;
23+
this.#targetingContextAccessor = options?.targetingContextAccessor;
2124

22-
const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()];
23-
25+
const builtinFilters = [new TimeWindowFilter(), new TargetingFilter(options?.targetingContextAccessor)];
2426
// If a custom filter shares a name with an existing filter, the custom filter overrides the existing one.
2527
for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) {
2628
this.#featureFilters.set(filter.name, filter);
2729
}
28-
29-
this.#onFeatureEvaluated = options?.onFeatureEvaluated;
3030
}
3131

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

81-
async #isEnabled(featureFlag: FeatureFlag, context?: unknown): Promise<boolean> {
81+
async #isEnabled(featureFlag: FeatureFlag, appContext?: unknown): Promise<boolean> {
8282
if (featureFlag.enabled !== true) {
8383
// If the feature is not explicitly enabled, then it is disabled by default.
8484
return false;
@@ -106,7 +106,7 @@ export class FeatureManager implements IFeatureManager {
106106
console.warn(`Feature filter ${clientFilter.name} is not found.`);
107107
return false;
108108
}
109-
if (await matchedFeatureFilter.evaluate(contextWithFeatureName, context) === shortCircuitEvaluationResult) {
109+
if (await matchedFeatureFilter.evaluate(contextWithFeatureName, appContext) === shortCircuitEvaluationResult) {
110110
return shortCircuitEvaluationResult;
111111
}
112112
}
@@ -115,7 +115,7 @@ export class FeatureManager implements IFeatureManager {
115115
return !shortCircuitEvaluationResult;
116116
}
117117

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

@@ -128,9 +128,10 @@ export class FeatureManager implements IFeatureManager {
128128
validateFeatureFlagFormat(featureFlag);
129129

130130
// Evaluate if the feature is enabled.
131-
result.enabled = await this.#isEnabled(featureFlag, context);
131+
result.enabled = await this.#isEnabled(featureFlag, appContext);
132132

133-
const targetingContext = context as ITargetingContext;
133+
// Get targeting context from the app context or the targeting context accessor
134+
const targetingContext = this.#getTargetingContext(appContext);
134135
result.targetingId = targetingContext?.userId;
135136

136137
// Determine Variant
@@ -151,7 +152,7 @@ export class FeatureManager implements IFeatureManager {
151152
}
152153
} else {
153154
// enabled, assign based on allocation
154-
if (context !== undefined && featureFlag.allocation !== undefined) {
155+
if (targetingContext !== undefined && featureFlag.allocation !== undefined) {
155156
const variantAndReason = await this.#assignVariant(featureFlag, targetingContext);
156157
variantDef = variantAndReason.variant;
157158
reason = variantAndReason.reason;
@@ -189,6 +190,16 @@ export class FeatureManager implements IFeatureManager {
189190

190191
return result;
191192
}
193+
194+
#getTargetingContext(context: unknown): ITargetingContext | undefined {
195+
let targetingContext: ITargetingContext | undefined = context as ITargetingContext;
196+
if (targetingContext?.userId === undefined &&
197+
targetingContext?.groups === undefined &&
198+
this.#targetingContextAccessor !== undefined) {
199+
targetingContext = this.#targetingContextAccessor.getTargetingContext();
200+
}
201+
return targetingContext;
202+
}
192203
}
193204

194205
export interface FeatureManagerOptions {
@@ -202,6 +213,11 @@ export interface FeatureManagerOptions {
202213
* The callback function is called only when telemetry is enabled for the feature flag.
203214
*/
204215
onFeatureEvaluated?: (event: EvaluationResult) => void;
216+
217+
/**
218+
* The accessor function that provides the @see ITargetingContext for targeting evaluation.
219+
*/
220+
targetingContextAccessor?: ITargetingContextAccessor;
205221
}
206222

207223
export class EvaluationResult {

src/feature-management/src/filter/TargetingFilter.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { IFeatureFilter } from "./FeatureFilter.js";
55
import { isTargetedPercentile } from "../common/targetingEvaluator.js";
6-
import { ITargetingContext } from "../common/ITargetingContext.js";
6+
import { ITargetingContext, ITargetingContextAccessor } from "../common/targetingContext.js";
77

88
type TargetingFilterParameters = {
99
Audience: {
@@ -26,48 +26,56 @@ type TargetingFilterEvaluationContext = {
2626
}
2727

2828
export class TargetingFilter implements IFeatureFilter {
29-
name: string = "Microsoft.Targeting";
29+
readonly name: string = "Microsoft.Targeting";
30+
readonly #targetingContextAccessor?: ITargetingContextAccessor;
31+
32+
constructor(targetingContextAccessor?: ITargetingContextAccessor) {
33+
this.#targetingContextAccessor = targetingContextAccessor;
34+
}
3035

3136
async evaluate(context: TargetingFilterEvaluationContext, appContext?: ITargetingContext): Promise<boolean> {
3237
const { featureName, parameters } = context;
3338
TargetingFilter.#validateParameters(featureName, parameters);
3439

35-
if (appContext === undefined) {
36-
throw new Error("The app context is required for targeting filter.");
40+
let targetingContext: ITargetingContext | undefined;
41+
if (appContext?.userId !== undefined || appContext?.groups !== undefined) {
42+
targetingContext = appContext;
43+
} else if (this.#targetingContextAccessor !== undefined) {
44+
targetingContext = this.#targetingContextAccessor.getTargetingContext();
3745
}
3846

3947
if (parameters.Audience.Exclusion !== undefined) {
4048
// check if the user is in the exclusion list
41-
if (appContext?.userId !== undefined &&
49+
if (targetingContext?.userId !== undefined &&
4250
parameters.Audience.Exclusion.Users !== undefined &&
43-
parameters.Audience.Exclusion.Users.includes(appContext.userId)) {
51+
parameters.Audience.Exclusion.Users.includes(targetingContext.userId)) {
4452
return false;
4553
}
4654
// check if the user is in a group within exclusion list
47-
if (appContext?.groups !== undefined &&
55+
if (targetingContext?.groups !== undefined &&
4856
parameters.Audience.Exclusion.Groups !== undefined) {
4957
for (const excludedGroup of parameters.Audience.Exclusion.Groups) {
50-
if (appContext.groups.includes(excludedGroup)) {
58+
if (targetingContext.groups.includes(excludedGroup)) {
5159
return false;
5260
}
5361
}
5462
}
5563
}
5664

5765
// check if the user is being targeted directly
58-
if (appContext?.userId !== undefined &&
66+
if (targetingContext?.userId !== undefined &&
5967
parameters.Audience.Users !== undefined &&
60-
parameters.Audience.Users.includes(appContext.userId)) {
68+
parameters.Audience.Users.includes(targetingContext.userId)) {
6169
return true;
6270
}
6371

6472
// check if the user is in a group that is being targeted
65-
if (appContext?.groups !== undefined &&
73+
if (targetingContext?.groups !== undefined &&
6674
parameters.Audience.Groups !== undefined) {
6775
for (const group of parameters.Audience.Groups) {
68-
if (appContext.groups.includes(group.Name)) {
76+
if (targetingContext.groups.includes(group.Name)) {
6977
const hint = `${featureName}\n${group.Name}`;
70-
if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) {
78+
if (await isTargetedPercentile(targetingContext.userId, hint, 0, group.RolloutPercentage)) {
7179
return true;
7280
}
7381
}
@@ -76,7 +84,7 @@ export class TargetingFilter implements IFeatureFilter {
7684

7785
// check if the user is being targeted by a default rollout percentage
7886
const hint = featureName;
79-
return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage);
87+
return isTargetedPercentile(targetingContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage);
8088
}
8189

8290
static #validateParameters(featureName: string, parameters: TargetingFilterParameters): void {

src/feature-management/src/filter/TimeWindowFilter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type TimeWindowFilterEvaluationContext = {
1515
}
1616

1717
export class TimeWindowFilter implements IFeatureFilter {
18-
name: string = "Microsoft.TimeWindow";
18+
readonly name: string = "Microsoft.TimeWindow";
1919

2020
evaluate(context: TimeWindowFilterEvaluationContext): boolean {
2121
const {featureName, parameters} = context;

src/feature-management/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export { FeatureManager, FeatureManagerOptions, EvaluationResult, VariantAssignm
55
export { ConfigurationMapFeatureFlagProvider, ConfigurationObjectFeatureFlagProvider, IFeatureFlagProvider } from "./featureProvider.js";
66
export { createFeatureEvaluationEventProperties } from "./telemetry/featureEvaluationEvent.js";
77
export { IFeatureFilter } from "./filter/FeatureFilter.js";
8+
export { ITargetingContext, ITargetingContextAccessor } from "./common/targetingContext.js";
89
export { VERSION } from "./version.js";

src/feature-management/test/targetingFilter.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,31 @@ describe("targeting filter", () => {
131131
]);
132132
});
133133

134-
it("should throw error if app context is not provided", () => {
134+
it("should evaluate feature with targeting filter with targeting context accessor", async () => {
135135
const dataSource = new Map();
136136
dataSource.set("feature_management", {
137137
feature_flags: [complexTargetingFeature]
138138
});
139139

140+
let userId = "";
141+
let groups: string[] = [];
142+
const testTargetingContextAccessor = {
143+
getTargetingContext: () => {
144+
return { userId: userId, groups: groups };
145+
}
146+
};
140147
const provider = new ConfigurationMapFeatureFlagProvider(dataSource);
141-
const featureManager = new FeatureManager(provider);
142-
143-
return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter.");
148+
const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor});
149+
150+
userId = "Aiden";
151+
expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(false);
152+
userId = "Blossom";
153+
expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(true);
154+
expect(await featureManager.isEnabled("ComplexTargeting", {userId: "Aiden"})).to.eq(false); // targeting id will be overridden
155+
userId = "Aiden";
156+
groups = ["Stage2"];
157+
expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(true);
158+
userId = "Chris";
159+
expect(await featureManager.isEnabled("ComplexTargeting")).to.eq(false);
144160
});
145161
});

src/feature-management/test/variant.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,32 @@ describe("feature variant", () => {
9090
});
9191

9292
});
93+
});
9394

95+
describe("variant assignment with targeting context accessor", () => {
96+
it("should assign variant based on targeting context accessor", async () => {
97+
let userId = "";
98+
let groups: string[] = [];
99+
const testTargetingContextAccessor = {
100+
getTargetingContext: () => {
101+
return { userId: userId, groups: groups };
102+
}
103+
};
104+
const provider = new ConfigurationObjectFeatureFlagProvider(featureFlagsConfigurationObject);
105+
const featureManager = new FeatureManager(provider, {targetingContextAccessor: testTargetingContextAccessor});
106+
userId = "Marsha";
107+
let variant = await featureManager.getVariant(Features.VariantFeatureUser);
108+
expect(variant).not.to.be.undefined;
109+
expect(variant?.name).eq("Small");
110+
userId = "Jeff";
111+
variant = await featureManager.getVariant(Features.VariantFeatureUser);
112+
expect(variant).to.be.undefined;
113+
variant = await featureManager.getVariant(Features.VariantFeatureUser, {userId: "Marsha"}); // targeting id will be overridden
114+
expect(variant).not.to.be.undefined;
115+
expect(variant?.name).eq("Small");
116+
groups = ["Group1"];
117+
variant = await featureManager.getVariant(Features.VariantFeatureGroup);
118+
expect(variant).not.to.be.undefined;
119+
expect(variant?.name).eq("Small");
120+
});
94121
});

0 commit comments

Comments
 (0)