ExperimentManager.php
161 lines
| 1 | <?php |
| 2 | |
| 3 | namespace WPStaging\Framework\Experiments; |
| 4 | |
| 5 | /** |
| 6 | * Assigns this installation to a variant of an A/B experiment and remembers it. |
| 7 | * |
| 8 | * There is no central service to hand out buckets, so the split is drawn locally |
| 9 | * and stored in wp_options. Once written it is never re-drawn. |
| 10 | * |
| 11 | * @see ExperimentsRegistry for the list of experiments. |
| 12 | */ |
| 13 | class ExperimentManager |
| 14 | { |
| 15 | /** @var string Map of experiment id => assigned variant. Not autoloaded. */ |
| 16 | const OPTION_ASSIGNMENTS = 'wpstg_experiments'; |
| 17 | |
| 18 | /** @var ExperimentsRegistry */ |
| 19 | private $registry; |
| 20 | |
| 21 | /** @var array<string, string>|null */ |
| 22 | private $assignments = null; |
| 23 | |
| 24 | public function __construct(ExperimentsRegistry $registry) |
| 25 | { |
| 26 | $this->registry = $registry; |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * The variant this installation belongs to, assigning one on first call. |
| 31 | * |
| 32 | * Call this only from a surface that has already decided the installation is |
| 33 | * eligible, so ineligible installations are never enrolled. |
| 34 | * |
| 35 | * @return string The variant name, or an empty string when the experiment |
| 36 | * is unknown or no longer running and nothing was assigned. |
| 37 | */ |
| 38 | public function getVariant(string $experimentId): string |
| 39 | { |
| 40 | $assigned = $this->getAssignedVariant($experimentId); |
| 41 | if ($assigned !== '') { |
| 42 | return $assigned; |
| 43 | } |
| 44 | |
| 45 | $experiment = $this->registry->get($experimentId); |
| 46 | if ($experiment === null || !$experiment->isRunning()) { |
| 47 | return ''; |
| 48 | } |
| 49 | |
| 50 | return $this->assign($experiment); |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * The stored variant, without enrolling the installation. |
| 55 | * |
| 56 | * @return string Empty when this installation was never assigned, or when |
| 57 | * the stored variant is no longer part of the experiment. |
| 58 | */ |
| 59 | public function getAssignedVariant(string $experimentId): string |
| 60 | { |
| 61 | $assignments = $this->getAssignments(); |
| 62 | if (!isset($assignments[$experimentId])) { |
| 63 | return ''; |
| 64 | } |
| 65 | |
| 66 | $experiment = $this->registry->get($experimentId); |
| 67 | if ($experiment === null || !$experiment->hasVariant($assignments[$experimentId])) { |
| 68 | return ''; |
| 69 | } |
| 70 | |
| 71 | return $assignments[$experimentId]; |
| 72 | } |
| 73 | |
| 74 | public function isVariant(string $experimentId, string $variant): bool |
| 75 | { |
| 76 | return $this->getAssignedVariant($experimentId) === $variant; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Experiment identity to attach to analytics events, so a staging site or |
| 81 | * backup created long after the onboarding is still attributable. |
| 82 | * |
| 83 | * If several are ever assigned, the first declared one wins so the value |
| 84 | * stays a stable scalar pair. |
| 85 | * |
| 86 | * @return array An `experiment` and `variant` pair, empty when this |
| 87 | * installation takes part in no experiment. |
| 88 | */ |
| 89 | public function getAttribution(): array |
| 90 | { |
| 91 | foreach ($this->registry->all() as $experiment) { |
| 92 | $variant = $this->getAssignedVariant($experiment->getId()); |
| 93 | if ($variant === '') { |
| 94 | continue; |
| 95 | } |
| 96 | |
| 97 | return [ |
| 98 | 'experiment' => $experiment->getId(), |
| 99 | 'variant' => $variant, |
| 100 | ]; |
| 101 | } |
| 102 | |
| 103 | return []; |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Forget every assignment, so the next call draws again. |
| 108 | */ |
| 109 | public function reset() |
| 110 | { |
| 111 | $this->assignments = null; |
| 112 | delete_option(self::OPTION_ASSIGNMENTS); |
| 113 | } |
| 114 | |
| 115 | private function assign(Experiment $experiment): string |
| 116 | { |
| 117 | // Not derived from the site: host names and salts correlate with hosting |
| 118 | // provider and site age, which would bias the buckets. |
| 119 | $variants = $experiment->getVariants(); |
| 120 | $variant = $variants[wp_rand(0, count($variants) - 1)]; |
| 121 | |
| 122 | // add_option() is atomic, so the loser of a race reads back the winner's draw. |
| 123 | if (add_option(self::OPTION_ASSIGNMENTS, [$experiment->getId() => $variant], '', false)) { |
| 124 | $this->assignments = [$experiment->getId() => $variant]; |
| 125 | |
| 126 | return $variant; |
| 127 | } |
| 128 | |
| 129 | $this->assignments = null; |
| 130 | |
| 131 | $assigned = $this->getAssignedVariant($experiment->getId()); |
| 132 | if ($assigned !== '') { |
| 133 | return $assigned; |
| 134 | } |
| 135 | |
| 136 | $assignments = $this->getAssignments(); |
| 137 | $assignments[$experiment->getId()] = $variant; |
| 138 | update_option(self::OPTION_ASSIGNMENTS, $assignments, false); |
| 139 | $this->assignments = $assignments; |
| 140 | |
| 141 | return $variant; |
| 142 | } |
| 143 | |
| 144 | private function getAssignments(): array |
| 145 | { |
| 146 | if ($this->assignments !== null) { |
| 147 | return $this->assignments; |
| 148 | } |
| 149 | |
| 150 | $assignments = get_option(self::OPTION_ASSIGNMENTS, []); |
| 151 | |
| 152 | if (!is_array($assignments)) { |
| 153 | $assignments = []; |
| 154 | } |
| 155 | |
| 156 | $this->assignments = array_filter($assignments, 'is_string'); |
| 157 | |
| 158 | return $this->assignments; |
| 159 | } |
| 160 | } |
| 161 |