@microsoft/feature-management 1.0.0 → 2.0.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/commonjs/common/targetingEvaluator.js +119 -0
  2. package/dist/commonjs/common/targetingEvaluator.js.map +1 -0
  3. package/dist/commonjs/featureManager.js +183 -7
  4. package/dist/commonjs/featureManager.js.map +1 -1
  5. package/dist/commonjs/featureProvider.js +2 -2
  6. package/dist/commonjs/featureProvider.js.map +1 -1
  7. package/dist/commonjs/filter/TargetingFilter.js +6 -74
  8. package/dist/commonjs/filter/TargetingFilter.js.map +1 -1
  9. package/dist/commonjs/filter/TimeWindowFilter.js.map +1 -1
  10. package/dist/commonjs/index.js +5 -0
  11. package/dist/commonjs/index.js.map +1 -1
  12. package/dist/commonjs/model.js.map +1 -1
  13. package/dist/commonjs/variant/Variant.js +15 -0
  14. package/dist/commonjs/variant/Variant.js.map +1 -0
  15. package/dist/commonjs/version.js +1 -1
  16. package/dist/commonjs/version.js.map +1 -1
  17. package/dist/esm/common/targetingEvaluator.js +115 -0
  18. package/dist/esm/common/targetingEvaluator.js.map +1 -0
  19. package/dist/esm/featureManager.js +183 -8
  20. package/dist/esm/featureManager.js.map +1 -1
  21. package/dist/esm/featureProvider.js +2 -2
  22. package/dist/esm/featureProvider.js.map +1 -1
  23. package/dist/esm/filter/TargetingFilter.js +6 -74
  24. package/dist/esm/filter/TargetingFilter.js.map +1 -1
  25. package/dist/esm/filter/TimeWindowFilter.js.map +1 -1
  26. package/dist/esm/index.js +1 -1
  27. package/dist/esm/model.js.map +1 -1
  28. package/dist/esm/variant/Variant.js +13 -0
  29. package/dist/esm/variant/Variant.js.map +1 -0
  30. package/dist/esm/version.js +1 -1
  31. package/dist/esm/version.js.map +1 -1
  32. package/dist/umd/index.js +310 -82
  33. package/dist/umd/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/types/index.d.ts +82 -13
package/dist/umd/index.js CHANGED
@@ -22,6 +22,119 @@
22
22
  }
23
23
  }
24
24
 
25
+ // Copyright (c) Microsoft Corporation.
26
+ // Licensed under the MIT license.
27
+ /**
28
+ * Determines if the user is part of the audience, based on the user id and the percentage range.
29
+ *
30
+ * @param userId user id from app context
31
+ * @param hint hint string to be included in the context id
32
+ * @param from percentage range start
33
+ * @param to percentage range end
34
+ * @returns true if the user is part of the audience, false otherwise
35
+ */
36
+ async function isTargetedPercentile(userId, hint, from, to) {
37
+ if (from < 0 || from > 100) {
38
+ throw new Error("The 'from' value must be between 0 and 100.");
39
+ }
40
+ if (to < 0 || to > 100) {
41
+ throw new Error("The 'to' value must be between 0 and 100.");
42
+ }
43
+ if (from > to) {
44
+ throw new Error("The 'from' value cannot be larger than the 'to' value.");
45
+ }
46
+ const audienceContextId = constructAudienceContextId(userId, hint);
47
+ // Cryptographic hashing algorithms ensure adequate entropy across hash values.
48
+ const contextMarker = await stringToUint32(audienceContextId);
49
+ const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100;
50
+ // Handle edge case of exact 100 bucket
51
+ if (to === 100) {
52
+ return contextPercentage >= from;
53
+ }
54
+ return contextPercentage >= from && contextPercentage < to;
55
+ }
56
+ /**
57
+ * Determines if the user is part of the audience, based on the groups they belong to.
58
+ *
59
+ * @param sourceGroups user groups from app context
60
+ * @param targetedGroups targeted groups from feature configuration
61
+ * @returns true if the user is part of the audience, false otherwise
62
+ */
63
+ function isTargetedGroup(sourceGroups, targetedGroups) {
64
+ if (sourceGroups === undefined) {
65
+ return false;
66
+ }
67
+ return sourceGroups.some(group => targetedGroups.includes(group));
68
+ }
69
+ /**
70
+ * Determines if the user is part of the audience, based on the user id.
71
+ * @param userId user id from app context
72
+ * @param users targeted users from feature configuration
73
+ * @returns true if the user is part of the audience, false otherwise
74
+ */
75
+ function isTargetedUser(userId, users) {
76
+ if (userId === undefined) {
77
+ return false;
78
+ }
79
+ return users.includes(userId);
80
+ }
81
+ /**
82
+ * Constructs the context id for the audience.
83
+ * The context id is used to determine if the user is part of the audience for a feature.
84
+ *
85
+ * @param userId userId from app context
86
+ * @param hint hint string to be included in the context id
87
+ * @returns a string that represents the context id for the audience
88
+ */
89
+ function constructAudienceContextId(userId, hint) {
90
+ return `${userId ?? ""}\n${hint}`;
91
+ }
92
+ /**
93
+ * Converts a string to a uint32 in little-endian encoding.
94
+ * @param str the string to convert.
95
+ * @returns a uint32 value.
96
+ */
97
+ async function stringToUint32(str) {
98
+ let crypto;
99
+ // Check for browser environment
100
+ if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
101
+ crypto = window.crypto;
102
+ }
103
+ // Check for Node.js environment
104
+ else if (typeof global !== "undefined" && global.crypto) {
105
+ crypto = global.crypto;
106
+ }
107
+ // Fallback to native Node.js crypto module
108
+ else {
109
+ try {
110
+ if (typeof module !== "undefined" && module.exports) {
111
+ crypto = require("crypto");
112
+ }
113
+ else {
114
+ crypto = await import('crypto');
115
+ }
116
+ }
117
+ catch (error) {
118
+ console.error("Failed to load the crypto module:", error.message);
119
+ throw error;
120
+ }
121
+ }
122
+ // In the browser, use crypto.subtle.digest
123
+ if (crypto.subtle) {
124
+ const data = new TextEncoder().encode(str);
125
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
126
+ const dataView = new DataView(hashBuffer);
127
+ const uint32 = dataView.getUint32(0, true);
128
+ return uint32;
129
+ }
130
+ // In Node.js, use the crypto module's hash function
131
+ else {
132
+ const hash = crypto.createHash("sha256").update(str).digest();
133
+ const uint32 = hash.readUInt32LE(0);
134
+ return uint32;
135
+ }
136
+ }
137
+
25
138
  // Copyright (c) Microsoft Corporation.
26
139
  // Licensed under the MIT license.
27
140
  class TargetingFilter {
@@ -60,26 +173,16 @@
60
173
  parameters.Audience.Groups !== undefined) {
61
174
  for (const group of parameters.Audience.Groups) {
62
175
  if (appContext.groups.includes(group.Name)) {
63
- const audienceContextId = constructAudienceContextId(featureName, appContext.userId, group.Name);
64
- const rolloutPercentage = group.RolloutPercentage;
65
- if (await TargetingFilter.#isTargeted(audienceContextId, rolloutPercentage)) {
176
+ const hint = `${featureName}\n${group.Name}`;
177
+ if (await isTargetedPercentile(appContext.userId, hint, 0, group.RolloutPercentage)) {
66
178
  return true;
67
179
  }
68
180
  }
69
181
  }
70
182
  }
71
183
  // check if the user is being targeted by a default rollout percentage
72
- const defaultContextId = constructAudienceContextId(featureName, appContext?.userId);
73
- return TargetingFilter.#isTargeted(defaultContextId, parameters.Audience.DefaultRolloutPercentage);
74
- }
75
- static async #isTargeted(audienceContextId, rolloutPercentage) {
76
- if (rolloutPercentage === 100) {
77
- return true;
78
- }
79
- // Cryptographic hashing algorithms ensure adequate entropy across hash values.
80
- const contextMarker = await stringToUint32(audienceContextId);
81
- const contextPercentage = (contextMarker / 0xFFFFFFFF) * 100;
82
- return contextPercentage < rolloutPercentage;
184
+ const hint = featureName;
185
+ return isTargetedPercentile(appContext?.userId, hint, 0, parameters.Audience.DefaultRolloutPercentage);
83
186
  }
84
187
  static #validateParameters(parameters) {
85
188
  if (parameters.Audience.DefaultRolloutPercentage < 0 || parameters.Audience.DefaultRolloutPercentage > 100) {
@@ -95,64 +198,15 @@
95
198
  }
96
199
  }
97
200
  }
98
- /**
99
- * Constructs the context id for the audience.
100
- * The context id is used to determine if the user is part of the audience for a feature.
101
- * If groupName is provided, the context id is constructed as follows:
102
- * userId + "\n" + featureName + "\n" + groupName
103
- * Otherwise, the context id is constructed as follows:
104
- * userId + "\n" + featureName
105
- *
106
- * @param featureName name of the feature
107
- * @param userId userId from app context
108
- * @param groupName group name from app context
109
- * @returns a string that represents the context id for the audience
110
- */
111
- function constructAudienceContextId(featureName, userId, groupName) {
112
- let contextId = `${userId ?? ""}\n${featureName}`;
113
- if (groupName !== undefined) {
114
- contextId += `\n${groupName}`;
115
- }
116
- return contextId;
117
- }
118
- async function stringToUint32(str) {
119
- let crypto;
120
- // Check for browser environment
121
- if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) {
122
- crypto = window.crypto;
123
- }
124
- // Check for Node.js environment
125
- else if (typeof global !== "undefined" && global.crypto) {
126
- crypto = global.crypto;
127
- }
128
- // Fallback to native Node.js crypto module
129
- else {
130
- try {
131
- if (typeof module !== "undefined" && module.exports) {
132
- crypto = require("crypto");
133
- }
134
- else {
135
- crypto = await import('crypto');
136
- }
137
- }
138
- catch (error) {
139
- console.error("Failed to load the crypto module:", error.message);
140
- throw error;
141
- }
142
- }
143
- // In the browser, use crypto.subtle.digest
144
- if (crypto.subtle) {
145
- const data = new TextEncoder().encode(str);
146
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
147
- const dataView = new DataView(hashBuffer);
148
- const uint32 = dataView.getUint32(0, true);
149
- return uint32;
150
- }
151
- // In Node.js, use the crypto module's hash function
152
- else {
153
- const hash = crypto.createHash("sha256").update(str).digest();
154
- const uint32 = hash.readUInt32LE(0);
155
- return uint32;
201
+
202
+ // Copyright (c) Microsoft Corporation.
203
+ // Licensed under the MIT license.
204
+ class Variant {
205
+ name;
206
+ configuration;
207
+ constructor(name, configuration) {
208
+ this.name = name;
209
+ this.configuration = configuration;
156
210
  }
157
211
  }
158
212
 
@@ -161,6 +215,7 @@
161
215
  class FeatureManager {
162
216
  #provider;
163
217
  #featureFilters = new Map();
218
+ #onFeatureEvaluated;
164
219
  constructor(provider, options) {
165
220
  this.#provider = provider;
166
221
  const builtinFilters = [new TimeWindowFilter(), new TargetingFilter()];
@@ -168,6 +223,7 @@
168
223
  for (const filter of [...builtinFilters, ...(options?.customFilters ?? [])]) {
169
224
  this.#featureFilters.set(filter.name, filter);
170
225
  }
226
+ this.#onFeatureEvaluated = options?.onFeatureEvaluated;
171
227
  }
172
228
  async listFeatureNames() {
173
229
  const features = await this.#provider.getFeatureFlags();
@@ -176,13 +232,42 @@
176
232
  }
177
233
  // If multiple feature flags are found, the first one takes precedence.
178
234
  async isEnabled(featureName, context) {
179
- const featureFlag = await this.#provider.getFeatureFlag(featureName);
180
- if (featureFlag === undefined) {
181
- // If the feature is not found, then it is disabled.
182
- return false;
235
+ const result = await this.#evaluateFeature(featureName, context);
236
+ return result.enabled;
237
+ }
238
+ async getVariant(featureName, context) {
239
+ const result = await this.#evaluateFeature(featureName, context);
240
+ return result.variant;
241
+ }
242
+ async #assignVariant(featureFlag, context) {
243
+ // user allocation
244
+ if (featureFlag.allocation?.user !== undefined) {
245
+ for (const userAllocation of featureFlag.allocation.user) {
246
+ if (isTargetedUser(context.userId, userAllocation.users)) {
247
+ return getVariantAssignment(featureFlag, userAllocation.variant, exports.VariantAssignmentReason.User);
248
+ }
249
+ }
183
250
  }
184
- // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard.
185
- validateFeatureFlagFormat(featureFlag);
251
+ // group allocation
252
+ if (featureFlag.allocation?.group !== undefined) {
253
+ for (const groupAllocation of featureFlag.allocation.group) {
254
+ if (isTargetedGroup(context.groups, groupAllocation.groups)) {
255
+ return getVariantAssignment(featureFlag, groupAllocation.variant, exports.VariantAssignmentReason.Group);
256
+ }
257
+ }
258
+ }
259
+ // percentile allocation
260
+ if (featureFlag.allocation?.percentile !== undefined) {
261
+ for (const percentileAllocation of featureFlag.allocation.percentile) {
262
+ const hint = featureFlag.allocation.seed ?? `allocation\n${featureFlag.id}`;
263
+ if (await isTargetedPercentile(context.userId, hint, percentileAllocation.from, percentileAllocation.to)) {
264
+ return getVariantAssignment(featureFlag, percentileAllocation.variant, exports.VariantAssignmentReason.Percentile);
265
+ }
266
+ }
267
+ }
268
+ return { variant: undefined, reason: exports.VariantAssignmentReason.None };
269
+ }
270
+ async #isEnabled(featureFlag, context) {
186
271
  if (featureFlag.enabled !== true) {
187
272
  // If the feature is not explicitly enabled, then it is disabled by default.
188
273
  return false;
@@ -201,7 +286,7 @@
201
286
  const shortCircuitEvaluationResult = requirementType === "Any";
202
287
  for (const clientFilter of clientFilters) {
203
288
  const matchedFeatureFilter = this.#featureFilters.get(clientFilter.name);
204
- const contextWithFeatureName = { featureName, parameters: clientFilter.parameters };
289
+ const contextWithFeatureName = { featureName: featureFlag.id, parameters: clientFilter.parameters };
205
290
  if (matchedFeatureFilter === undefined) {
206
291
  console.warn(`Feature filter ${clientFilter.name} is not found.`);
207
292
  return false;
@@ -213,11 +298,153 @@
213
298
  // If we get here, then we have not found a client filter that matches the requirement type.
214
299
  return !shortCircuitEvaluationResult;
215
300
  }
301
+ async #evaluateFeature(featureName, context) {
302
+ const featureFlag = await this.#provider.getFeatureFlag(featureName);
303
+ const result = new EvaluationResult(featureFlag);
304
+ if (featureFlag === undefined) {
305
+ return result;
306
+ }
307
+ // Ensure that the feature flag is in the correct format. Feature providers should validate the feature flags, but we do it here as a safeguard.
308
+ // TODO: move to the feature flag provider implementation.
309
+ validateFeatureFlagFormat(featureFlag);
310
+ // Evaluate if the feature is enabled.
311
+ result.enabled = await this.#isEnabled(featureFlag, context);
312
+ const targetingContext = context;
313
+ result.targetingId = targetingContext?.userId;
314
+ // Determine Variant
315
+ let variantDef;
316
+ let reason = exports.VariantAssignmentReason.None;
317
+ // featureFlag.variant not empty
318
+ if (featureFlag.variants !== undefined && featureFlag.variants.length > 0) {
319
+ if (!result.enabled) {
320
+ // not enabled, assign default if specified
321
+ if (featureFlag.allocation?.default_when_disabled !== undefined) {
322
+ variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_disabled);
323
+ reason = exports.VariantAssignmentReason.DefaultWhenDisabled;
324
+ }
325
+ else {
326
+ // no default specified
327
+ variantDef = undefined;
328
+ reason = exports.VariantAssignmentReason.DefaultWhenDisabled;
329
+ }
330
+ }
331
+ else {
332
+ // enabled, assign based on allocation
333
+ if (context !== undefined && featureFlag.allocation !== undefined) {
334
+ const variantAndReason = await this.#assignVariant(featureFlag, targetingContext);
335
+ variantDef = variantAndReason.variant;
336
+ reason = variantAndReason.reason;
337
+ }
338
+ // allocation failed, assign default if specified
339
+ if (variantDef === undefined && reason === exports.VariantAssignmentReason.None) {
340
+ if (featureFlag.allocation?.default_when_enabled !== undefined) {
341
+ variantDef = featureFlag.variants.find(v => v.name == featureFlag.allocation?.default_when_enabled);
342
+ reason = exports.VariantAssignmentReason.DefaultWhenEnabled;
343
+ }
344
+ else {
345
+ variantDef = undefined;
346
+ reason = exports.VariantAssignmentReason.DefaultWhenEnabled;
347
+ }
348
+ }
349
+ }
350
+ }
351
+ result.variant = variantDef !== undefined ? new Variant(variantDef.name, variantDef.configuration_value) : undefined;
352
+ result.variantAssignmentReason = reason;
353
+ // Status override for isEnabled
354
+ if (variantDef !== undefined && featureFlag.enabled) {
355
+ if (variantDef.status_override === "Enabled") {
356
+ result.enabled = true;
357
+ }
358
+ else if (variantDef.status_override === "Disabled") {
359
+ result.enabled = false;
360
+ }
361
+ }
362
+ // The callback will only be executed if telemetry is enabled for the feature flag
363
+ if (featureFlag.telemetry?.enabled && this.#onFeatureEvaluated !== undefined) {
364
+ this.#onFeatureEvaluated(result);
365
+ }
366
+ return result;
367
+ }
216
368
  }
369
+ class EvaluationResult {
370
+ feature;
371
+ enabled;
372
+ targetingId;
373
+ variant;
374
+ variantAssignmentReason;
375
+ constructor(
376
+ // feature flag definition
377
+ feature,
378
+ // enabled state
379
+ enabled = false,
380
+ // variant assignment
381
+ targetingId = undefined, variant = undefined, variantAssignmentReason = exports.VariantAssignmentReason.None) {
382
+ this.feature = feature;
383
+ this.enabled = enabled;
384
+ this.targetingId = targetingId;
385
+ this.variant = variant;
386
+ this.variantAssignmentReason = variantAssignmentReason;
387
+ }
388
+ }
389
+ exports.VariantAssignmentReason = void 0;
390
+ (function (VariantAssignmentReason) {
391
+ /**
392
+ * Variant allocation did not happen. No variant is assigned.
393
+ */
394
+ VariantAssignmentReason["None"] = "None";
395
+ /**
396
+ * The default variant is assigned when a feature flag is disabled.
397
+ */
398
+ VariantAssignmentReason["DefaultWhenDisabled"] = "DefaultWhenDisabled";
399
+ /**
400
+ * The default variant is assigned because of no applicable user/group/percentile allocation when a feature flag is enabled.
401
+ */
402
+ VariantAssignmentReason["DefaultWhenEnabled"] = "DefaultWhenEnabled";
403
+ /**
404
+ * The variant is assigned because of the user allocation when a feature flag is enabled.
405
+ */
406
+ VariantAssignmentReason["User"] = "User";
407
+ /**
408
+ * The variant is assigned because of the group allocation when a feature flag is enabled.
409
+ */
410
+ VariantAssignmentReason["Group"] = "Group";
411
+ /**
412
+ * The variant is assigned because of the percentile allocation when a feature flag is enabled.
413
+ */
414
+ VariantAssignmentReason["Percentile"] = "Percentile";
415
+ })(exports.VariantAssignmentReason || (exports.VariantAssignmentReason = {}));
416
+ /**
417
+ * Validates the format of the feature flag definition.
418
+ *
419
+ * FeatureFlag data objects are from IFeatureFlagProvider, depending on the implementation.
420
+ * Thus the properties are not guaranteed to have the expected types.
421
+ *
422
+ * @param featureFlag The feature flag definition to validate.
423
+ */
217
424
  function validateFeatureFlagFormat(featureFlag) {
218
425
  if (featureFlag.enabled !== undefined && typeof featureFlag.enabled !== "boolean") {
219
426
  throw new Error(`Feature flag ${featureFlag.id} has an invalid 'enabled' value.`);
220
427
  }
428
+ // TODO: add more validations.
429
+ // TODO: should be moved to the feature flag provider.
430
+ }
431
+ /**
432
+ * Try to get the variant assignment for the given variant name. If the variant is not found, override the reason with VariantAssignmentReason.None.
433
+ *
434
+ * @param featureFlag feature flag definition
435
+ * @param variantName variant name
436
+ * @param reason variant assignment reason
437
+ * @returns variant assignment containing the variant definition and the reason
438
+ */
439
+ function getVariantAssignment(featureFlag, variantName, reason) {
440
+ const variant = featureFlag.variants?.find(v => v.name == variantName);
441
+ if (variant !== undefined) {
442
+ return { variant, reason };
443
+ }
444
+ else {
445
+ console.warn(`Variant ${variantName} not found for feature ${featureFlag.id}.`);
446
+ return { variant: undefined, reason: exports.VariantAssignmentReason.None };
447
+ }
221
448
  }
222
449
 
223
450
  // Copyright (c) Microsoft Corporation.
@@ -239,7 +466,7 @@
239
466
  }
240
467
  async getFeatureFlag(featureName) {
241
468
  const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY);
242
- return featureConfig?.[FEATURE_FLAGS_KEY]?.findLast((feature) => feature.id === featureName);
469
+ return featureConfig?.[FEATURE_FLAGS_KEY]?.find((feature) => feature.id === featureName);
243
470
  }
244
471
  async getFeatureFlags() {
245
472
  const featureConfig = this.#configuration.get(FEATURE_MANAGEMENT_KEY);
@@ -256,7 +483,7 @@
256
483
  }
257
484
  async getFeatureFlag(featureName) {
258
485
  const featureFlags = this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY];
259
- return featureFlags?.findLast((feature) => feature.id === featureName);
486
+ return featureFlags?.find((feature) => feature.id === featureName);
260
487
  }
261
488
  async getFeatureFlags() {
262
489
  return this.#configuration[FEATURE_MANAGEMENT_KEY]?.[FEATURE_FLAGS_KEY] ?? [];
@@ -265,10 +492,11 @@
265
492
 
266
493
  // Copyright (c) Microsoft Corporation.
267
494
  // Licensed under the MIT license.
268
- const VERSION = "1.0.0";
495
+ const VERSION = "2.0.0-preview.2";
269
496
 
270
497
  exports.ConfigurationMapFeatureFlagProvider = ConfigurationMapFeatureFlagProvider;
271
498
  exports.ConfigurationObjectFeatureFlagProvider = ConfigurationObjectFeatureFlagProvider;
499
+ exports.EvaluationResult = EvaluationResult;
272
500
  exports.FeatureManager = FeatureManager;
273
501
  exports.VERSION = VERSION;
274
502