@featurevisor/sdk 1.29.3 → 1.30.1

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.
@@ -0,0 +1,772 @@
1
+ import {
2
+ Feature,
3
+ FeatureKey,
4
+ Context,
5
+ BucketKey,
6
+ BucketValue,
7
+ RuleKey,
8
+ Traffic,
9
+ Force,
10
+ Required,
11
+ OverrideFeature,
12
+ Variation,
13
+ VariationValue,
14
+ VariableKey,
15
+ VariableValue,
16
+ VariableSchema,
17
+ StickyFeatures,
18
+ InitialFeatures,
19
+ Allocation,
20
+ } from "@featurevisor/types";
21
+
22
+ import { Logger } from "./logger";
23
+ import { DatafileReader } from "./datafileReader";
24
+ import { getBucket, ConfigureBucketKey, ConfigureBucketValue } from "./bucket";
25
+ import {
26
+ getMatchedTraffic,
27
+ getMatchedTrafficAndAllocation,
28
+ findForceFromFeature,
29
+ parseFromStringifiedSegments,
30
+ } from "./feature";
31
+ import { allConditionsAreMatched } from "./conditions";
32
+ import { allGroupSegmentsAreMatched } from "./segments";
33
+ import type { Statuses, InterceptContext } from "./instance";
34
+
35
+ export enum EvaluationReason {
36
+ NOT_FOUND = "not_found",
37
+ NO_VARIATIONS = "no_variations",
38
+ NO_MATCH = "no_match",
39
+ DISABLED = "disabled",
40
+ REQUIRED = "required",
41
+ OUT_OF_RANGE = "out_of_range",
42
+ FORCED = "forced",
43
+ INITIAL = "initial",
44
+ STICKY = "sticky",
45
+ RULE = "rule",
46
+ ALLOCATED = "allocated",
47
+ DEFAULTED = "defaulted",
48
+ OVERRIDE = "override",
49
+ ERROR = "error",
50
+ }
51
+
52
+ type EvaluationType = "flag" | "variation" | "variable";
53
+
54
+ export interface Evaluation {
55
+ // required
56
+ // type: EvaluationType; // @TODO: bring in later
57
+ featureKey: FeatureKey;
58
+ reason: EvaluationReason;
59
+
60
+ // common
61
+ bucketKey?: BucketKey;
62
+ bucketValue?: BucketValue;
63
+ ruleKey?: RuleKey;
64
+ error?: Error;
65
+ enabled?: boolean;
66
+ traffic?: Traffic;
67
+ forceIndex?: number;
68
+ force?: Force;
69
+ required?: Required[];
70
+ sticky?: OverrideFeature;
71
+ initial?: OverrideFeature;
72
+
73
+ // variation
74
+ variation?: Variation;
75
+ variationValue?: VariationValue;
76
+
77
+ // variable
78
+ variableKey?: VariableKey;
79
+ variableValue?: VariableValue;
80
+ variableSchema?: VariableSchema;
81
+ }
82
+
83
+ export interface EvaluateOptions {
84
+ type: EvaluationType;
85
+
86
+ featureKey: FeatureKey | Feature;
87
+ variableKey?: VariableKey;
88
+ context: Context;
89
+
90
+ logger: Logger;
91
+ datafileReader: DatafileReader;
92
+ statuses?: Statuses;
93
+ interceptContext?: InterceptContext;
94
+
95
+ stickyFeatures?: StickyFeatures;
96
+ initialFeatures?: InitialFeatures;
97
+
98
+ bucketKeySeparator?: string;
99
+ configureBucketKey?: ConfigureBucketKey;
100
+ configureBucketValue?: ConfigureBucketValue;
101
+ }
102
+
103
+ export function evaluate(options: EvaluateOptions): Evaluation {
104
+ let evaluation: Evaluation;
105
+ const {
106
+ type,
107
+ featureKey,
108
+ variableKey,
109
+ context,
110
+ logger,
111
+ datafileReader,
112
+ statuses,
113
+ stickyFeatures,
114
+ initialFeatures,
115
+ interceptContext,
116
+ bucketKeySeparator,
117
+ configureBucketKey,
118
+ configureBucketValue,
119
+ } = options;
120
+
121
+ try {
122
+ const key = typeof featureKey === "string" ? featureKey : featureKey.key;
123
+
124
+ /**
125
+ * Root flag evaluation
126
+ */
127
+ let flag: Evaluation;
128
+ if (type !== "flag") {
129
+ // needed by variation and variable evaluations
130
+ flag = evaluate({
131
+ type: "flag",
132
+ featureKey: key,
133
+ context,
134
+ logger,
135
+ datafileReader,
136
+ statuses,
137
+ stickyFeatures,
138
+ initialFeatures,
139
+ bucketKeySeparator,
140
+ configureBucketKey,
141
+ configureBucketValue,
142
+ });
143
+
144
+ if (flag.enabled === false) {
145
+ evaluation = {
146
+ featureKey: key,
147
+ reason: EvaluationReason.DISABLED,
148
+ };
149
+
150
+ logger.debug("feature is disabled", evaluation);
151
+
152
+ return evaluation;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Sticky
158
+ */
159
+ if (stickyFeatures && stickyFeatures[key]) {
160
+ // flag
161
+ if (type === "flag" && typeof stickyFeatures[key].enabled !== "undefined") {
162
+ evaluation = {
163
+ featureKey: key,
164
+ reason: EvaluationReason.STICKY,
165
+ sticky: stickyFeatures[key],
166
+ enabled: stickyFeatures[key].enabled,
167
+ };
168
+
169
+ logger.debug("using sticky enabled", evaluation);
170
+
171
+ return evaluation;
172
+ }
173
+
174
+ // variation
175
+ if (type === "variation") {
176
+ const variationValue = stickyFeatures[key].variation;
177
+
178
+ if (typeof variationValue !== "undefined") {
179
+ evaluation = {
180
+ featureKey: key,
181
+ reason: EvaluationReason.STICKY,
182
+ variationValue,
183
+ };
184
+
185
+ logger.debug("using sticky variation", evaluation);
186
+
187
+ return evaluation;
188
+ }
189
+ }
190
+
191
+ // variable
192
+ if (variableKey) {
193
+ const variables = stickyFeatures[key].variables;
194
+
195
+ if (variables) {
196
+ const result = variables[variableKey];
197
+
198
+ if (typeof result !== "undefined") {
199
+ evaluation = {
200
+ featureKey: key,
201
+ reason: EvaluationReason.STICKY,
202
+ variableKey,
203
+ variableValue: result,
204
+ };
205
+
206
+ logger.debug("using sticky variable", evaluation);
207
+
208
+ return evaluation;
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Initial
216
+ */
217
+ if (statuses && !statuses.ready && initialFeatures && initialFeatures[key]) {
218
+ // flag
219
+ if (type === "flag" && typeof initialFeatures[key].enabled !== "undefined") {
220
+ evaluation = {
221
+ featureKey: key,
222
+ reason: EvaluationReason.INITIAL,
223
+ initial: initialFeatures[key],
224
+ enabled: initialFeatures[key].enabled,
225
+ };
226
+
227
+ logger.debug("using initial enabled", evaluation);
228
+
229
+ return evaluation;
230
+ }
231
+
232
+ // variation
233
+ if (type === "variation" && typeof initialFeatures[key].variation !== "undefined") {
234
+ const variationValue = initialFeatures[key].variation;
235
+
236
+ evaluation = {
237
+ featureKey: key,
238
+ reason: EvaluationReason.INITIAL,
239
+ variationValue,
240
+ };
241
+
242
+ logger.debug("using initial variation", evaluation);
243
+
244
+ return evaluation;
245
+ }
246
+
247
+ // variable
248
+ if (variableKey) {
249
+ const variables = initialFeatures[key].variables;
250
+
251
+ if (variables) {
252
+ if (typeof variables[variableKey] !== "undefined") {
253
+ evaluation = {
254
+ featureKey: key,
255
+ reason: EvaluationReason.INITIAL,
256
+ variableKey,
257
+ variableValue: variables[variableKey],
258
+ };
259
+
260
+ logger.debug("using initial variable", evaluation);
261
+
262
+ return evaluation;
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Feature
270
+ */
271
+ const feature =
272
+ typeof featureKey === "string" ? datafileReader.getFeature(featureKey) : featureKey;
273
+
274
+ // feature: not found
275
+ if (!feature) {
276
+ evaluation = {
277
+ featureKey: key,
278
+ reason: EvaluationReason.NOT_FOUND, // @TODO: make it type-specific
279
+ };
280
+
281
+ logger.warn("feature not found", evaluation);
282
+
283
+ return evaluation;
284
+ }
285
+
286
+ // feature: deprecated
287
+ if (type === "flag" && feature.deprecated) {
288
+ logger.warn("feature is deprecated", { featureKey: feature.key });
289
+ }
290
+
291
+ // variableSchema
292
+ let variableSchema: VariableSchema | undefined;
293
+
294
+ if (variableKey) {
295
+ variableSchema = Array.isArray(feature.variablesSchema)
296
+ ? feature.variablesSchema.find((v) => v.key === variableKey)
297
+ : undefined;
298
+
299
+ // variable schema not found
300
+ if (!variableSchema) {
301
+ evaluation = {
302
+ featureKey: key,
303
+ reason: EvaluationReason.NOT_FOUND,
304
+ variableKey,
305
+ };
306
+
307
+ logger.warn("variable schema not found", evaluation);
308
+
309
+ return evaluation;
310
+ }
311
+ }
312
+
313
+ // variation: no variations
314
+ if (type === "variation" && (!feature.variations || feature.variations.length === 0)) {
315
+ evaluation = {
316
+ featureKey: key,
317
+ reason: EvaluationReason.NO_VARIATIONS,
318
+ };
319
+
320
+ logger.warn("no variations", evaluation);
321
+
322
+ return evaluation;
323
+ }
324
+
325
+ const finalContext = interceptContext ? interceptContext(context) : context;
326
+
327
+ /**
328
+ * Forced
329
+ */
330
+ const { force, forceIndex } = findForceFromFeature(feature, context, datafileReader, logger);
331
+
332
+ if (force) {
333
+ // flag
334
+ if (type === "flag" && typeof force.enabled !== "undefined") {
335
+ evaluation = {
336
+ featureKey: feature.key,
337
+ reason: EvaluationReason.FORCED,
338
+ forceIndex,
339
+ force,
340
+ enabled: force.enabled,
341
+ };
342
+
343
+ logger.debug("forced enabled found", evaluation);
344
+
345
+ return evaluation;
346
+ }
347
+
348
+ // variation
349
+ if (type === "variation" && force.variation && feature.variations) {
350
+ const variation = feature.variations.find((v) => v.value === force.variation);
351
+
352
+ if (variation) {
353
+ evaluation = {
354
+ featureKey: feature.key,
355
+ reason: EvaluationReason.FORCED,
356
+ forceIndex,
357
+ force,
358
+ variation,
359
+ };
360
+
361
+ logger.debug("forced variation found", evaluation);
362
+
363
+ return evaluation;
364
+ }
365
+ }
366
+
367
+ // variable
368
+ if (variableKey && force.variables && typeof force.variables[variableKey] !== "undefined") {
369
+ evaluation = {
370
+ featureKey: feature.key,
371
+ reason: EvaluationReason.FORCED,
372
+ forceIndex,
373
+ force,
374
+ variableKey,
375
+ variableSchema,
376
+ variableValue: force.variables[variableKey],
377
+ };
378
+
379
+ logger.debug("forced variable", evaluation);
380
+
381
+ return evaluation;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Required
387
+ */
388
+ if (type === "flag" && feature.required && feature.required.length > 0) {
389
+ const requiredFeaturesAreEnabled = feature.required.every((required) => {
390
+ let requiredKey;
391
+ let requiredVariation;
392
+
393
+ if (typeof required === "string") {
394
+ requiredKey = required;
395
+ } else {
396
+ requiredKey = required.key;
397
+ requiredVariation = required.variation;
398
+ }
399
+
400
+ const requiredEvaluation = evaluate({
401
+ type: "flag",
402
+ featureKey: requiredKey,
403
+ context: finalContext,
404
+ logger,
405
+ datafileReader,
406
+ statuses,
407
+ stickyFeatures,
408
+ initialFeatures,
409
+ bucketKeySeparator,
410
+ configureBucketKey,
411
+ configureBucketValue,
412
+ });
413
+ const requiredIsEnabled = requiredEvaluation.enabled;
414
+
415
+ if (!requiredIsEnabled) {
416
+ return false;
417
+ }
418
+
419
+ if (typeof requiredVariation !== "undefined") {
420
+ const requiredVariationEvaluation = evaluate({
421
+ type: "variation",
422
+ featureKey: requiredKey,
423
+ context: finalContext,
424
+ logger,
425
+ datafileReader,
426
+ statuses,
427
+ stickyFeatures,
428
+ initialFeatures,
429
+ bucketKeySeparator,
430
+ configureBucketKey,
431
+ configureBucketValue,
432
+ });
433
+
434
+ let requiredVariationValue;
435
+
436
+ if (requiredVariationEvaluation.variationValue) {
437
+ requiredVariationValue = requiredVariationEvaluation.variationValue;
438
+ } else if (requiredVariationEvaluation.variation) {
439
+ requiredVariationValue = requiredVariationEvaluation.variation.value;
440
+ }
441
+
442
+ return requiredVariationValue === requiredVariation;
443
+ }
444
+
445
+ return true;
446
+ });
447
+
448
+ if (!requiredFeaturesAreEnabled) {
449
+ evaluation = {
450
+ featureKey: feature.key,
451
+ reason: EvaluationReason.REQUIRED,
452
+ required: feature.required,
453
+ enabled: requiredFeaturesAreEnabled,
454
+ };
455
+
456
+ logger.debug("required features not enabled", evaluation);
457
+
458
+ return evaluation;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Bucketing
464
+ */
465
+ const { bucketKey, bucketValue } = getBucket({
466
+ feature,
467
+ context: finalContext,
468
+ logger,
469
+ bucketKeySeparator,
470
+ configureBucketKey,
471
+ configureBucketValue,
472
+ });
473
+
474
+ let matchedTraffic: Traffic | undefined;
475
+ let matchedAllocation: Allocation | undefined;
476
+
477
+ if (type !== "flag") {
478
+ const matched = getMatchedTrafficAndAllocation(
479
+ feature.traffic,
480
+ finalContext,
481
+ bucketValue,
482
+ datafileReader,
483
+ logger,
484
+ );
485
+
486
+ matchedTraffic = matched.matchedTraffic;
487
+ matchedAllocation = matched.matchedAllocation;
488
+ } else {
489
+ matchedTraffic = getMatchedTraffic(feature.traffic, finalContext, datafileReader, logger);
490
+ }
491
+
492
+ if (matchedTraffic) {
493
+ // flag
494
+ if (type === "flag") {
495
+ // flag: check if mutually exclusive
496
+ if (feature.ranges && feature.ranges.length > 0) {
497
+ const matchedRange = feature.ranges.find((range) => {
498
+ return bucketValue >= range[0] && bucketValue < range[1];
499
+ });
500
+
501
+ // matched
502
+ if (matchedRange) {
503
+ evaluation = {
504
+ featureKey: feature.key,
505
+ reason: EvaluationReason.ALLOCATED,
506
+ bucketKey,
507
+ bucketValue,
508
+ ruleKey: matchedTraffic.key,
509
+ traffic: matchedTraffic,
510
+ enabled:
511
+ typeof matchedTraffic.enabled === "undefined" ? true : matchedTraffic.enabled,
512
+ };
513
+
514
+ logger.debug("matched", evaluation);
515
+
516
+ return evaluation;
517
+ }
518
+
519
+ // no match
520
+ evaluation = {
521
+ featureKey: feature.key,
522
+ reason: EvaluationReason.OUT_OF_RANGE,
523
+ bucketKey,
524
+ bucketValue,
525
+ enabled: false,
526
+ };
527
+
528
+ logger.debug("not matched", evaluation);
529
+
530
+ return evaluation;
531
+ }
532
+
533
+ // flag: override from rule
534
+ if (typeof matchedTraffic.enabled !== "undefined") {
535
+ evaluation = {
536
+ featureKey: feature.key,
537
+ reason: EvaluationReason.OVERRIDE,
538
+ bucketKey,
539
+ bucketValue,
540
+ ruleKey: matchedTraffic.key,
541
+ traffic: matchedTraffic,
542
+ enabled: matchedTraffic.enabled,
543
+ };
544
+
545
+ logger.debug("override from rule", evaluation);
546
+
547
+ return evaluation;
548
+ }
549
+
550
+ // treated as enabled because of matched traffic
551
+ if (bucketValue <= matchedTraffic.percentage) {
552
+ evaluation = {
553
+ featureKey: feature.key,
554
+ reason: EvaluationReason.RULE,
555
+ bucketKey,
556
+ bucketValue,
557
+ ruleKey: matchedTraffic.key,
558
+ traffic: matchedTraffic,
559
+ enabled: true,
560
+ };
561
+
562
+ logger.debug("matched traffic", evaluation);
563
+
564
+ return evaluation;
565
+ }
566
+ }
567
+
568
+ // variation
569
+ if (type === "variation" && feature.variations) {
570
+ // override from rule
571
+ if (matchedTraffic.variation) {
572
+ const variation = feature.variations.find((v) => v.value === matchedTraffic.variation);
573
+
574
+ if (variation) {
575
+ evaluation = {
576
+ featureKey: feature.key,
577
+ reason: EvaluationReason.RULE,
578
+ bucketKey,
579
+ bucketValue,
580
+ ruleKey: matchedTraffic.key,
581
+ traffic: matchedTraffic,
582
+ variation,
583
+ };
584
+
585
+ logger.debug("override from rule", evaluation);
586
+
587
+ return evaluation;
588
+ }
589
+ }
590
+
591
+ // regular allocation
592
+ if (matchedAllocation && matchedAllocation.variation) {
593
+ const variation = feature.variations.find((v) => v.value === matchedAllocation.variation);
594
+
595
+ if (variation) {
596
+ evaluation = {
597
+ featureKey: feature.key,
598
+ reason: EvaluationReason.ALLOCATED,
599
+ bucketKey,
600
+ bucketValue,
601
+ ruleKey: matchedTraffic.key,
602
+ traffic: matchedTraffic,
603
+ variation,
604
+ };
605
+
606
+ logger.debug("allocated variation", evaluation);
607
+
608
+ return evaluation;
609
+ }
610
+ }
611
+ }
612
+ }
613
+
614
+ // variable
615
+ if (type === "variable" && variableKey) {
616
+ // override from rule
617
+ if (
618
+ matchedTraffic &&
619
+ matchedTraffic.variables &&
620
+ typeof matchedTraffic.variables[variableKey] !== "undefined"
621
+ ) {
622
+ evaluation = {
623
+ featureKey: feature.key,
624
+ reason: EvaluationReason.RULE,
625
+ bucketKey,
626
+ bucketValue,
627
+ ruleKey: matchedTraffic.key,
628
+ traffic: matchedTraffic,
629
+ variableKey,
630
+ variableSchema,
631
+ variableValue: matchedTraffic.variables[variableKey],
632
+ };
633
+
634
+ logger.debug("override from rule", evaluation);
635
+
636
+ return evaluation;
637
+ }
638
+
639
+ // check variations
640
+ let variationValue;
641
+
642
+ if (force && force.variation) {
643
+ variationValue = force.variation;
644
+ } else if (matchedAllocation && matchedAllocation.variation) {
645
+ variationValue = matchedAllocation.variation;
646
+ }
647
+
648
+ if (variationValue && Array.isArray(feature.variations)) {
649
+ const variation = feature.variations.find((v) => v.value === variationValue);
650
+
651
+ if (variation && variation.variables) {
652
+ const variableFromVariation = variation.variables.find((v) => v.key === variableKey);
653
+
654
+ if (variableFromVariation) {
655
+ if (variableFromVariation.overrides) {
656
+ const override = variableFromVariation.overrides.find((o) => {
657
+ if (o.conditions) {
658
+ return allConditionsAreMatched(
659
+ typeof o.conditions === "string" ? JSON.parse(o.conditions) : o.conditions,
660
+ finalContext,
661
+ logger,
662
+ );
663
+ }
664
+
665
+ if (o.segments) {
666
+ return allGroupSegmentsAreMatched(
667
+ parseFromStringifiedSegments(o.segments),
668
+ finalContext,
669
+ datafileReader,
670
+ logger,
671
+ );
672
+ }
673
+
674
+ return false;
675
+ });
676
+
677
+ if (override) {
678
+ evaluation = {
679
+ featureKey: feature.key,
680
+ reason: EvaluationReason.OVERRIDE,
681
+ bucketKey,
682
+ bucketValue,
683
+ ruleKey: matchedTraffic?.key,
684
+ traffic: matchedTraffic,
685
+ variableKey,
686
+ variableSchema,
687
+ variableValue: override.value,
688
+ };
689
+
690
+ logger.debug("variable override", evaluation);
691
+
692
+ return evaluation;
693
+ }
694
+ }
695
+
696
+ if (typeof variableFromVariation.value !== "undefined") {
697
+ evaluation = {
698
+ featureKey: feature.key,
699
+ reason: EvaluationReason.ALLOCATED,
700
+ bucketKey,
701
+ bucketValue,
702
+ ruleKey: matchedTraffic?.key,
703
+ traffic: matchedTraffic,
704
+ variableKey,
705
+ variableSchema,
706
+ variableValue: variableFromVariation.value,
707
+ };
708
+
709
+ logger.debug("allocated variable", evaluation);
710
+
711
+ return evaluation;
712
+ }
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Nothing matched
720
+ */
721
+ if (type === "variation") {
722
+ evaluation = {
723
+ featureKey: feature.key,
724
+ reason: EvaluationReason.NO_MATCH,
725
+ bucketKey,
726
+ bucketValue,
727
+ };
728
+
729
+ logger.debug("no matched variation", evaluation);
730
+
731
+ return evaluation;
732
+ }
733
+
734
+ if (type === "variable" && variableSchema) {
735
+ evaluation = {
736
+ featureKey: feature.key,
737
+ reason: EvaluationReason.DEFAULTED,
738
+ bucketKey,
739
+ bucketValue,
740
+ variableKey,
741
+ variableSchema,
742
+ variableValue: variableSchema.defaultValue,
743
+ };
744
+
745
+ logger.debug("using default value", evaluation);
746
+
747
+ return evaluation;
748
+ }
749
+
750
+ evaluation = {
751
+ featureKey: feature.key,
752
+ reason: EvaluationReason.NO_MATCH,
753
+ bucketKey,
754
+ bucketValue,
755
+ enabled: false,
756
+ };
757
+
758
+ logger.debug("nothing matched", evaluation);
759
+
760
+ return evaluation;
761
+ } catch (e) {
762
+ evaluation = {
763
+ featureKey: typeof featureKey === "string" ? featureKey : featureKey.key,
764
+ reason: EvaluationReason.ERROR,
765
+ error: e,
766
+ };
767
+
768
+ logger.error("error", evaluation);
769
+
770
+ return evaluation;
771
+ }
772
+ }