All files / src/builder traffic.ts

89.55% Statements 60/67
82.69% Branches 43/52
100% Functions 12/12
89.39% Lines 59/66

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193                4x   4x           26x 16x     11x       5x     10x 22x     10x   23x                       26x 14x     12x   12x             26x 14x     12x 12x           25x                 24x       24x   24x 26x   26x                   26x 7x     26x         26x 26x 26x 26x     26x                 26x   26x   2x   2x 3x         3x   3x     2x     26x 21x 38x   38x         38x   38x     38x   38x 33x 33x             38x             26x 26x 36x       36x     26x 5x       26x     24x    
import type {
  Rule,
  ExistingFeature,
  Traffic,
  Variation,
  Range,
  Percentage,
} from "@featurevisor/types";
import { MAX_BUCKETED_NUMBER } from "@featurevisor/sdk";
 
import { getAllocation, getUpdatedAvailableRangesAfterFilling } from "./allocator";
 
export function detectIfVariationsChanged(
  yamlVariations: Variation[] | undefined, // as exists in latest YAML
  existingFeature?: ExistingFeature, // from state file
): boolean {
  if (!existingFeature || typeof existingFeature.variations === "undefined") {
    if (Array.isArray(yamlVariations) && yamlVariations.length > 0) {
      // feature did not previously have any variations,
      // but now variations have been added
      return true;
    }
 
    // variations didn't exist before, and not even now
    return false;
  }
 
  const checkVariations = Array.isArray(yamlVariations)
    ? JSON.stringify(yamlVariations.map(({ value, weight }) => ({ value, weight })))
    : undefined;
 
  return (
    JSON.stringify(
      existingFeature.variations.map(({ value, weight }) => ({
        value,
        weight,
      })),
    ) !== checkVariations
  );
}
 
export function getRulePercentageDiff(
  trafficPercentage: Percentage, // 0 to 100k
  existingTrafficRule,
): number {
  if (!existingTrafficRule) {
    return 0;
  }
 
  const existingPercentage = existingTrafficRule.percentage;
 
  return trafficPercentage - existingPercentage;
}
 
export function detectIfRangesChanged(
  availableRanges: Range[], // as exists in latest YAML
  existingFeature?: ExistingFeature, // from state file
): boolean {
  if (!existingFeature) {
    return false;
  }
 
  Eif (!existingFeature.ranges) {
    return false;
  }
 
  return JSON.stringify(existingFeature.ranges) !== JSON.stringify(availableRanges);
}
 
export function getTraffic(
  // from current YAML
  variations: Variation[] | undefined,
  parsedRules: Rule[],
  // from previous release
  existingFeature: ExistingFeature | undefined,
  // ranges from group slots
  ranges?: Range[],
): Traffic[] {
  const result: Traffic[] = [];
 
  // @NOTE: may be pass from builder directly?
  const availableRanges =
    ranges && ranges.length > 0 ? ranges : ([[0, MAX_BUCKETED_NUMBER]] as Range[]);
 
  parsedRules.forEach(function (parsedRule) {
    const rulePercentage = parsedRule.percentage; // 0 - 100
 
    const traffic: Traffic = {
      key: parsedRule.key,
      segments: parsedRule.segments,
      percentage: rulePercentage * (MAX_BUCKETED_NUMBER / 100),
      allocation: [],
      variationWeights: parsedRule.variationWeights,
      variableOverrides: parsedRule.variableOverrides,
    };
 
    // overrides
    if (parsedRule.variables) {
      traffic.variables = parsedRule.variables;
    }
 
    Iif (parsedRule.variation) {
      traffic.variation = parsedRule.variation;
    }
 
    // detect changes
    const variationsChanged = detectIfVariationsChanged(variations, existingFeature);
    const existingTrafficRule = existingFeature?.traffic.find((t) => t.key === parsedRule.key);
    const rulePercentageDiff = getRulePercentageDiff(traffic.percentage, existingTrafficRule);
    const rangesChanged = detectIfRangesChanged(availableRanges, existingFeature);
 
    const needsRebucketing =
      !existingTrafficRule || // new rule
      variationsChanged || // variations changed
      rulePercentageDiff < 0 || // percentage decreased
      rangesChanged || // belongs to a group, and group ranges changed
      // @NOTE: this means, if variationWeights is present, it will always rebucket.
      // worth checking if we can maintain consistent bucketing for this use case as well.
      // but this use case is unlikely to hit in practice because it doesn't matter if the feature itself is 100% rolled out.
      traffic.variationWeights; // variation weights overridden
 
    let updatedAvailableRanges = JSON.parse(JSON.stringify(availableRanges));
 
    if (existingTrafficRule && existingTrafficRule.allocation && !needsRebucketing) {
      // increase: build on top of existing allocations
      let existingSum = 0;
 
      traffic.allocation = existingTrafficRule.allocation.map(function ({ variation, range }) {
        const result = {
          variation,
          range: range,
        };
 
        existingSum += range[1] - range[0];
 
        return result;
      });
 
      updatedAvailableRanges = getUpdatedAvailableRangesAfterFilling(availableRanges, existingSum);
    }
 
    if (Array.isArray(variations)) {
      variations.forEach(function (variation) {
        let weight = variation.weight as number;
 
        Iif (traffic.variationWeights && traffic.variationWeights[variation.value]) {
          // override weight from rule
          weight = traffic.variationWeights[variation.value];
        }
 
        const percentage = weight * (MAX_BUCKETED_NUMBER / 100);
 
        const toFillValue = needsRebucketing
          ? percentage * (rulePercentage / 100) // whole value
          : (weight / 100) * rulePercentageDiff; // incrementing
        const rangesToFill = getAllocation(updatedAvailableRanges, toFillValue);
 
        rangesToFill.forEach(function (range) {
          Eif (traffic.allocation) {
            traffic.allocation.push({
              variation: variation.value,
              range,
            });
          }
        });
 
        updatedAvailableRanges = getUpdatedAvailableRangesAfterFilling(
          updatedAvailableRanges,
          toFillValue,
        );
      });
    }
 
    Eif (traffic.allocation) {
      traffic.allocation = traffic.allocation.filter((a) => {
        Iif (a.range && a.range[0] === a.range[1]) {
          return false;
        }
 
        return true;
      });
 
      if (traffic.allocation.length === 0) {
        delete traffic.allocation;
      }
    }
 
    result.push(traffic);
  });
 
  return result;
}