Skip to content

Commit 53fdca1

Browse files
authored
Add built-in TargetingFilter impl (#5)
1 parent 89358f5 commit 53fdca1

File tree

6 files changed

+296
-5
lines changed

6 files changed

+296
-5
lines changed

.eslintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"eol-last": [
4545
"error",
4646
"always"
47-
]
47+
],
48+
"no-trailing-spaces": "warn"
4849
}
4950
}

src/featureManager.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { TimewindowFilter } from "./filter/TimeWindowFilter";
4+
import { TimeWindowFilter } from "./filter/TimeWindowFilter";
55
import { IFeatureFilter } from "./filter/FeatureFilter";
66
import { RequirementType } from "./model";
77
import { IFeatureFlagProvider } from "./featureProvider";
8+
import { TargetingFilter } from "./filter/TargetingFilter";
89

910
export class FeatureManager {
1011
#provider: IFeatureFlagProvider;
@@ -13,7 +14,7 @@ export class FeatureManager {
1314
constructor(provider: IFeatureFlagProvider, options?: FeatureManagerOptions) {
1415
this.#provider = provider;
1516

16-
const builtinFilters = [new TimewindowFilter()]; // TODO: add TargetFilter as built-in filter.
17+
const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()];
1718

1819
// If a custom filter shares a name with an existing filter, the custom filter overrides the existing one.
1920
for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) {

src/filter/FeatureFilter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
export interface IFeatureFilter {
55
name: string; // e.g. Microsoft.TimeWindow
6-
evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): Promise<boolean> | boolean;
6+
evaluate(context: IFeatureFilterEvaluationContext, appContext?: unknown): boolean | Promise<boolean>;
77
}
88

99
export interface IFeatureFilterEvaluationContext {

src/filter/TargetingFilter.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { IFeatureFilter } from "./FeatureFilter";
5+
import { createHash } from "crypto";
6+
7+
type TargetingFilterParameters = {
8+
Audience: {
9+
DefaultRolloutPercentage: number;
10+
Users?: string[];
11+
Groups?: {
12+
Name: string;
13+
RolloutPercentage: number;
14+
}[];
15+
Exclusion?: {
16+
Users?: string[];
17+
Groups?: string[];
18+
};
19+
}
20+
}
21+
22+
type TargetingFilterEvaluationContext = {
23+
featureName: string;
24+
parameters: TargetingFilterParameters;
25+
}
26+
27+
type TargetingFilterAppContext = {
28+
userId?: string;
29+
groups?: string[];
30+
}
31+
32+
export class TargetingFilter implements IFeatureFilter {
33+
name: string = "Microsoft.Targeting";
34+
35+
evaluate(context: TargetingFilterEvaluationContext, appContext?: TargetingFilterAppContext): boolean | Promise<boolean> {
36+
const { featureName, parameters } = context;
37+
TargetingFilter.#validateParameters(parameters);
38+
39+
if (appContext === undefined) {
40+
throw new Error("The app context is required for targeting filter.");
41+
}
42+
43+
if (parameters.Audience.Exclusion !== undefined) {
44+
// check if the user is in the exclusion list
45+
if (appContext?.userId !== undefined &&
46+
parameters.Audience.Exclusion.Users !== undefined &&
47+
parameters.Audience.Exclusion.Users.includes(appContext.userId)) {
48+
return false;
49+
}
50+
// check if the user is in a group within exclusion list
51+
if (appContext?.groups !== undefined &&
52+
parameters.Audience.Exclusion.Groups !== undefined) {
53+
for (const excludedGroup of parameters.Audience.Exclusion.Groups) {
54+
if (appContext.groups.includes(excludedGroup)) {
55+
return false;
56+
}
57+
}
58+
}
59+
}
60+
61+
// check if the user is being targeted directly
62+
if (appContext?.userId !== undefined &&
63+
parameters.Audience.Users !== undefined &&
64+
parameters.Audience.Users.includes(appContext.userId)) {
65+
return true;
66+
}
67+
68+
// check if the user is in a group that is being targeted
69+
if (appContext?.groups !== undefined &&
70+
parameters.Audience.Groups !== undefined) {
71+
for (const group of parameters.Audience.Groups) {
72+
if (appContext.groups.includes(group.Name)) {
73+
const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name);
74+
const rolloutPercentage = group.RolloutPercentage;
75+
if (TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) {
76+
return true;
77+
}
78+
}
79+
}
80+
}
81+
82+
// check if the user is being targeted by a default rollout percentage
83+
const defaultContextId = constructAudienceContextId(featureName, appContext?.userId);
84+
return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage);
85+
}
86+
87+
static #isTargeted(audienceContextId: string, rolloutPercentage: number): boolean {
88+
if (rolloutPercentage === 100) {
89+
return true;
90+
}
91+
// Cryptographic hashing algorithms ensure adequate entropy across hash values.
92+
const contextMarker = stringToUint32(audienceContextId);
93+
const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100;
94+
return contextPercentage < rolloutPercentage;
95+
}
96+
97+
static #validateParameters(parameters: TargetingFilterParameters): void {
98+
if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) {
99+
throw new Error("Audience.DefaultRolloutPercentage must be a number between 0 and 100.");
100+
}
101+
// validate RolloutPercentage for each group
102+
if (parameters.Audience.Groups !== undefined) {
103+
for (const group of parameters.Audience.Groups) {
104+
if (group.RolloutPercentage < 0 || group.RolloutPercentage > 100) {
105+
throw new Error(`RolloutPercentage of group ${group.Name} must be a number between 0 and 100.`);
106+
}
107+
}
108+
}
109+
}
110+
}
111+
112+
/**
113+
* Constructs the context id for the audience.
114+
* The context id is used to determine if the user is part of the audience for a feature.
115+
* If groupName is provided, the context id is constructed as follows:
116+
* userId + "\n" + featureName + "\n" + groupName
117+
* Otherwise, the context id is constructed as follows:
118+
* userId + "\n" + featureName
119+
*
120+
* @param featureName name of the feature
121+
* @param userId userId from app context
122+
* @param groupName group name from app context
123+
* @returns a string that represents the context id for the audience
124+
*/
125+
function constructAudienceContextId(featureName: string, userId: string | undefined, groupName?: string) {
126+
let contextId = `${userId ?? ""}\n${featureName}`;
127+
if (groupName !== undefined) {
128+
contextId += `\n${groupName}`;
129+
}
130+
return contextId
131+
}
132+
133+
function stringToUint32(str: string): number {
134+
// Create a SHA-256 hash of the string
135+
const hash = createHash("sha256").update(str).digest();
136+
137+
// Get the first 4 bytes of the hash
138+
const first4Bytes = hash.subarray(0, 4);
139+
140+
// Convert the 4 bytes to a uint32 with little-endian encoding
141+
const uint32 = first4Bytes.readUInt32LE(0);
142+
return uint32;
143+
}

src/filter/TimeWindowFilter.ts

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

17-
export class TimewindowFilter implements IFeatureFilter {
17+
export class TimeWindowFilter implements IFeatureFilter {
1818
name: string = "Microsoft.TimeWindow";
1919

2020
evaluate(context: TimeWindowFilterEvaluationContext): boolean {

test/targetingFilter.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import * as chai from "chai";
5+
import * as chaiAsPromised from "chai-as-promised";
6+
chai.use(chaiAsPromised);
7+
const expect = chai.expect;
8+
9+
import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "./exportedApi";
10+
11+
const complexTargetingFeature = {
12+
"id": "ComplexTargeting",
13+
"description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.",
14+
"enabled": true,
15+
"conditions": {
16+
"client_filters": [
17+
{
18+
"name": "Microsoft.Targeting",
19+
"parameters": {
20+
"Audience": {
21+
"Users": [
22+
"Alice"
23+
],
24+
"Groups": [
25+
{
26+
"Name": "Stage1",
27+
"RolloutPercentage": 100
28+
},
29+
{
30+
"Name": "Stage2",
31+
"RolloutPercentage": 50
32+
}
33+
],
34+
"DefaultRolloutPercentage": 25,
35+
"Exclusion": {
36+
"Users": ["Dave"],
37+
"Groups": ["Stage3"]
38+
}
39+
}
40+
}
41+
}
42+
]
43+
}
44+
};
45+
46+
const createTargetingFeatureWithRolloutPercentage = (name: string, defaultRolloutPercentage: number, groups?: { Name: string, RolloutPercentage: number }[]) => {
47+
const featureFlag = {
48+
"id": name,
49+
"description": "A feature flag using a targeting filter with invalid parameters.",
50+
"enabled": true,
51+
"conditions": {
52+
"client_filters": [
53+
{
54+
"name": "Microsoft.Targeting",
55+
"parameters": {
56+
"Audience": {
57+
"DefaultRolloutPercentage": defaultRolloutPercentage
58+
}
59+
}
60+
}
61+
]
62+
}
63+
};
64+
if (groups && groups.length > 0) {
65+
(featureFlag.conditions.client_filters[0].parameters.Audience as any).Groups = groups;
66+
}
67+
return featureFlag;
68+
};
69+
70+
describe("targeting filter", () => {
71+
it("should validate parameters", () => {
72+
const dataSource = new Map();
73+
dataSource.set("feature_management", {
74+
feature_flags: [
75+
createTargetingFeatureWithRolloutPercentage("InvalidTargeting1", -1),
76+
createTargetingFeatureWithRolloutPercentage("InvalidTargeting2", 101),
77+
// invalid group rollout percentage
78+
createTargetingFeatureWithRolloutPercentage("InvalidTargeting3", 25, [{ Name: "Stage1", RolloutPercentage: -1 }]),
79+
createTargetingFeatureWithRolloutPercentage("InvalidTargeting4", 25, [{ Name: "Stage1", RolloutPercentage: 101 }]),
80+
]
81+
});
82+
83+
const provider = new ConfigurationMapFeatureFlagProvider(dataSource);
84+
const featureManager = new FeatureManager(provider);
85+
86+
return Promise.all([
87+
expect(featureManager.isEnabled("InvalidTargeting1", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."),
88+
expect(featureManager.isEnabled("InvalidTargeting2", {})).eventually.rejectedWith("Audience.DefaultRolloutPercentage must be a number between 0 and 100."),
89+
expect(featureManager.isEnabled("InvalidTargeting3", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."),
90+
expect(featureManager.isEnabled("InvalidTargeting4", {})).eventually.rejectedWith("RolloutPercentage of group Stage1 must be a number between 0 and 100."),
91+
]);
92+
});
93+
94+
it("should evaluate feature with targeting filter", () => {
95+
const dataSource = new Map();
96+
dataSource.set("feature_management", {
97+
feature_flags: [complexTargetingFeature]
98+
});
99+
100+
const provider = new ConfigurationMapFeatureFlagProvider(dataSource);
101+
const featureManager = new FeatureManager(provider);
102+
103+
return Promise.all([
104+
// default rollout 25%
105+
// - "Aiden\nComplexTargeting": ~62.9%
106+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden" })).eventually.eq(false, "Aiden is not in the 25% default rollout"),
107+
108+
// - "Blossom\nComplexTargeting": ~20.2%
109+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom" })).eventually.eq(true, "Blossom is in the 25% default rollout"),
110+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice" })).eventually.eq(true, "Alice is directly targeted"),
111+
112+
// Stage1 group is 100% rollout
113+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage1"] })).eventually.eq(true, "Aiden is in because Stage1 is 100% rollout"),
114+
115+
// Stage2 group is 50% rollout
116+
// - "\nComplexTargeting\nStage2": ~78.7% >= 50% (Stage2 is 50% rollout)
117+
// - "\nComplexTargeting": ~38.9% >= 25% (default rollout percentage)
118+
expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage2"] })).eventually.eq(false, "Empty user is not in the 50% rollout of group Stage2"),
119+
120+
// - "Aiden\nComplexTargeting\nStage2": ~15.6%
121+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Aiden", groups: ["Stage2"] })).eventually.eq(true, "Aiden is in the 50% rollout of group Stage2"),
122+
123+
// - "Chris\nComplexTargeting\nStage2": 55.3% >= 50% (Stage2 is 50% rollout)
124+
// - "Chris\nComplexTargeting": 72.3% >= 25% (default rollout percentage)
125+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Chris", groups: ["Stage2"] })).eventually.eq(false, "Chris is not in the 50% rollout of group Stage2"),
126+
127+
// exclusions
128+
expect(featureManager.isEnabled("ComplexTargeting", { groups: ["Stage3"] })).eventually.eq(false, "Stage3 group is excluded"),
129+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Alice", groups: ["Stage3"] })).eventually.eq(false, "Alice is excluded because she is part of Stage3 group"),
130+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Blossom", groups: ["Stage3"] })).eventually.eq(false, "Blossom is excluded because she is part of Stage3 group"),
131+
expect(featureManager.isEnabled("ComplexTargeting", { userId: "Dave", groups: ["Stage1"] })).eventually.eq(false, "Dave is excluded because he is in the exclusion list"),
132+
]);
133+
});
134+
135+
it("should throw error if app context is not provided", () => {
136+
const dataSource = new Map();
137+
dataSource.set("feature_management", {
138+
feature_flags: [complexTargetingFeature]
139+
});
140+
141+
const provider = new ConfigurationMapFeatureFlagProvider(dataSource);
142+
const featureManager = new FeatureManager(provider);
143+
144+
return expect(featureManager.isEnabled("ComplexTargeting")).eventually.rejectedWith("The app context is required for targeting filter.");
145+
});
146+
});

0 commit comments

Comments
 (0)