PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.10.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.10.0
4.10.0 4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Framework / Experiments / ExperimentManager.php
wp-staging / Framework / Experiments Last commit date
Experiment.php 4 days ago ExperimentManager.php 4 days ago ExperimentsRegistry.php 4 days ago
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