1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* InnoCraft Ltd - We are the makers of Piwik Analytics, the leading open source analytics platform. |
4
|
|
|
* |
5
|
|
|
* @link https://www.innocraft.com |
6
|
|
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html LGPL v3.0 |
7
|
|
|
*/ |
8
|
|
|
|
9
|
|
|
namespace InnoCraft\Experiments; |
10
|
|
|
|
11
|
|
|
use InnoCraft\Experiments\Filters\DefaultFilters; |
12
|
|
|
use InnoCraft\Experiments\Filters\FilterInterface; |
13
|
|
|
use InnoCraft\Experiments\Storage\Cookie; |
14
|
|
|
use InnoCraft\Experiments\Storage\StorageInterface; |
15
|
|
|
use InnoCraft\Experiments\Variations\VariationInterface; |
16
|
|
|
use InvalidArgumentException; |
17
|
|
|
use InnoCraft\Experiments\Variations\StandardVariation; |
18
|
|
|
use Exception; |
19
|
|
|
|
20
|
|
|
/** |
21
|
|
|
* Lets you create a new experiment to run an A/B test or a split test. |
22
|
|
|
*/ |
23
|
|
|
class Experiment { |
24
|
|
|
|
25
|
|
|
/** |
26
|
|
|
* Defines the name of the original version. |
27
|
|
|
*/ |
28
|
|
|
const ORIGINAL_VARIATION_NAME = 'original'; |
29
|
|
|
|
30
|
|
|
/** |
31
|
|
|
* Instead of the word 'original', one can also set '0' to mark a variation as the original version. |
32
|
|
|
*/ |
33
|
|
|
const ORIGINAL_VARIATION_ID = '0'; |
34
|
|
|
|
35
|
|
|
/** |
36
|
|
|
* Is returned by {@link getActivatedVariation()} when no variation should be activated. |
37
|
|
|
*/ |
38
|
|
|
const DO_NOT_TRIGGER = null; |
39
|
|
|
|
40
|
|
|
/** |
41
|
|
|
* @var int|string |
42
|
|
|
*/ |
43
|
|
|
private $name; |
44
|
|
|
|
45
|
|
|
/** |
46
|
|
|
* @var Variations |
47
|
|
|
*/ |
48
|
|
|
private $variations; |
49
|
|
|
|
50
|
|
|
/** |
51
|
|
|
* @var FilterInterface |
52
|
|
|
*/ |
53
|
|
|
private $filter; |
54
|
|
|
|
55
|
|
|
/** |
56
|
|
|
* @var StorageInterface |
57
|
|
|
*/ |
58
|
|
|
private $storage; |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* Creates a new experiment |
62
|
|
|
* |
63
|
|
|
* @param string $experimentNameOrId Can be any experiment name or an id of the experiment (eg as given by A/B Testing for Piwik) |
64
|
|
|
* @param array|VariationInterface[] $variations |
65
|
|
|
* @param array $config |
66
|
|
|
*/ |
67
|
23 |
|
public function __construct($experimentNameOrId, $variations, $config = []) |
68
|
|
|
{ |
69
|
23 |
|
if (!isset($experimentNameOrId) || $experimentNameOrId === false || $experimentNameOrId === '') { |
70
|
3 |
|
throw new InvalidArgumentException('no experimentNameOrId given'); |
71
|
|
|
} |
72
|
|
|
|
73
|
20 |
|
$this->name = $experimentNameOrId; |
74
|
|
|
|
75
|
20 |
|
if ($variations instanceof Variations) { |
76
|
2 |
|
$this->variations = $variations; |
77
|
2 |
|
} else { |
78
|
18 |
|
$this->variations = new Variations($experimentNameOrId, $variations); |
79
|
|
|
|
80
|
|
|
// in Piwik A/B Testing there is always an original variation, we need to force the existence here. |
81
|
|
|
// if you do not want to have this behaviour, instead pass an instance of Variations. |
82
|
18 |
|
if (!$this->variations->exists(Experiment::ORIGINAL_VARIATION_NAME) |
83
|
18 |
|
&& !$this->variations->exists(Experiment::ORIGINAL_VARIATION_ID)) { |
84
|
16 |
|
$this->variations->addVariation(new StandardVariation(['name' => Experiment::ORIGINAL_VARIATION_NAME])); |
85
|
16 |
|
} |
86
|
|
|
} |
87
|
|
|
|
88
|
20 |
View Code Duplication |
if (isset($config['storage']) && $config['storage'] instanceof StorageInterface) { |
|
|
|
|
89
|
19 |
|
$this->storage = $config['storage']; |
90
|
20 |
|
} elseif (isset($config['storage'])) { |
91
|
1 |
|
throw new InvalidArgumentException('storage needs to be an instance of StorageInterface'); |
92
|
|
|
} else { |
93
|
|
|
$this->storage = new Cookie(); |
94
|
|
|
} |
95
|
|
|
|
96
|
19 |
View Code Duplication |
if (isset($config['filter']) && $config['filter'] instanceof FilterInterface) { |
|
|
|
|
97
|
15 |
|
$this->filter = $config['filter']; |
98
|
19 |
|
} elseif (isset($config['filter'])) { |
99
|
1 |
|
throw new InvalidArgumentException('filter config needs to be an instance of FilterInterface'); |
100
|
|
|
} else { |
101
|
3 |
|
$this->filter = new DefaultFilters($this->name, $this->storage, $config); |
102
|
|
|
} |
103
|
18 |
|
} |
104
|
|
|
|
105
|
|
|
/** |
106
|
|
|
* Get the name of this experiment. |
107
|
|
|
* |
108
|
|
|
* @return int|string |
109
|
|
|
*/ |
110
|
7 |
|
public function getExperimentName() |
111
|
|
|
{ |
112
|
7 |
|
return $this->name; |
113
|
|
|
} |
114
|
|
|
|
115
|
|
|
/** |
116
|
|
|
* Forces the activation of the given variation name. |
117
|
|
|
* |
118
|
|
|
* @param string $variationName |
119
|
|
|
*/ |
120
|
4 |
|
public function forceVariationName($variationName) |
121
|
|
|
{ |
122
|
4 |
|
$this->storage->set('experiment', $this->name, $variationName); |
123
|
4 |
|
} |
124
|
|
|
|
125
|
|
|
/** |
126
|
|
|
* Detect whether any variation, including the original version, should be activated. |
127
|
|
|
* |
128
|
|
|
* Returns true if a variation should and will be activated when calling eg {@link getActivatedVariation()} |
129
|
|
|
* or {@link run()}, false if no variation will be activated. |
130
|
|
|
* |
131
|
|
|
* @return bool |
132
|
|
|
*/ |
133
|
|
|
public function shouldTrigger() |
134
|
|
|
{ |
135
|
|
|
return $this->getActivatedVariation() !== self::DO_NOT_TRIGGER; |
136
|
|
|
} |
137
|
|
|
|
138
|
|
|
/** |
139
|
|
|
* Get the activated variation for this experiment, or null if no variation was activated because of a set filter. |
140
|
|
|
* For example when the user does not take part in the experiment or when a scheduled date prevents the activation |
141
|
|
|
* of a variation. Returns the activated variation if no filter "blocked" it. On the first request, a variation |
142
|
|
|
* will be randomly chosen unless it was forced by {@link forceVariationName()}. On all subsequent requests |
143
|
|
|
* it will reuse the variation that was activated on the first request. |
144
|
|
|
* |
145
|
|
|
* @return VariationInterface|null |
146
|
|
|
*/ |
147
|
7 |
|
public function getActivatedVariation() |
148
|
|
|
{ |
149
|
7 |
|
if (!$this->filter->shouldTrigger()) { |
150
|
1 |
|
return self::DO_NOT_TRIGGER; |
151
|
|
|
} |
152
|
|
|
|
153
|
6 |
|
$variationName = $this->storage->get('experiment', $this->name); |
154
|
|
|
|
155
|
6 |
|
if (($variationName || $variationName === '0' || $variationName === 0) && $this->variations->exists($variationName)) { |
156
|
4 |
|
return $this->variations->get($variationName); |
157
|
|
|
} |
158
|
|
|
|
159
|
3 |
|
$variation = $this->variations->selectRandomVariation(); |
160
|
|
|
|
161
|
3 |
|
if ($variation) { |
162
|
2 |
|
$this->forceVariationName($variation->getName()); |
163
|
|
|
|
164
|
2 |
|
return $variation; |
165
|
|
|
} |
166
|
|
|
|
167
|
|
|
// when no variation exists |
168
|
1 |
|
return self::DO_NOT_TRIGGER; |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
/** |
172
|
|
|
* Get the set filter. |
173
|
|
|
* |
174
|
|
|
* @return FilterInterface |
175
|
|
|
*/ |
176
|
3 |
|
public function getFilter() |
177
|
|
|
{ |
178
|
3 |
|
return $this->filter; |
179
|
|
|
} |
180
|
|
|
|
181
|
|
|
/** |
182
|
|
|
* Get the set variations. |
183
|
|
|
* |
184
|
|
|
* @return Variations |
185
|
|
|
*/ |
186
|
6 |
|
public function getVariations() |
187
|
|
|
{ |
188
|
6 |
|
return $this->variations; |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
/** |
192
|
|
|
* Get the set storage. |
193
|
|
|
* @return Cookie|StorageInterface |
194
|
|
|
*/ |
195
|
4 |
|
public function getStorage() |
196
|
|
|
{ |
197
|
4 |
|
return $this->storage; |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
/** |
201
|
|
|
* Tracks the activation of a variation using for example the Piwik Tracker. This lets Piwik know which variation |
202
|
|
|
* was activated and should be used if you track your application using the Piwik Tracker server side. If you are |
203
|
|
|
* usually tracking using the JavaScript Tracker, have a look at {@link getTrackingScript()}. |
204
|
|
|
* |
205
|
|
|
* @param \stdClass|\PiwikTracker $tracker The passed object needs to implement a `doTrackEvent` method accepting |
206
|
|
|
* three parameters $category, $action, $name |
207
|
|
|
*/ |
208
|
3 |
|
public function trackVariationActivation($tracker) |
209
|
|
|
{ |
210
|
|
|
// we do not use an interface here for simplicity so it is not needed to use an adapter or something |
211
|
|
|
// for Piwik tracker |
212
|
3 |
|
if ($tracker && method_exists($tracker, 'doTrackEvent')) { |
213
|
1 |
|
$variation = $this->getActivatedVariation(); |
214
|
|
|
|
215
|
1 |
|
if ($variation === self::DO_NOT_TRIGGER) { |
216
|
|
|
return; |
217
|
|
|
} |
218
|
|
|
|
219
|
|
|
// eg PiwikTracker |
220
|
1 |
|
$tracker->doTrackEvent('abtesting', $this->getExperimentName(), $variation->getName()); |
221
|
1 |
|
} else { |
222
|
2 |
|
throw new InvalidArgumentException('The given tracker does not implement the doTrackEvent method'); |
223
|
|
|
} |
224
|
1 |
|
} |
225
|
|
|
|
226
|
|
|
/** |
227
|
|
|
* Returns the JavaScript tracking code that you can echo in your website to let Piwik know which variation was |
228
|
|
|
* activated server side. |
229
|
|
|
* |
230
|
|
|
* Do not pass variables from $_GET or $_POST etc. Make sure to escape the variables before passing them |
231
|
|
|
* to this method as you would otherwise risk an XSS. |
232
|
|
|
* |
233
|
|
|
* @param string $experimentName ExperimentName and VariationName needs to be passed cause we do not yet have a way |
234
|
|
|
* here to properly escape it to prevent XSS. |
235
|
|
|
* @param string $variationName |
236
|
|
|
* @return string The Piwik tracking code including the `<script>` elements and _paq.push(). |
237
|
|
|
*/ |
238
|
1 |
|
public function getTrackingScript($experimentName, $variationName) |
239
|
|
|
{ |
240
|
1 |
|
return sprintf('<script type="text/javascript">_paq.push(["AbTesting::enter", {experiment: "%s", variation: "%s"}]);</script>', $experimentName, $variationName); |
241
|
|
|
} |
242
|
|
|
|
243
|
|
|
/** |
244
|
|
|
* Generates a random integer by using the best method available. |
245
|
|
|
* |
246
|
|
|
* @param int $min Minimum value |
247
|
|
|
* @param int $max Maximum value |
248
|
|
|
* @return int|null |
249
|
|
|
*/ |
250
|
18 |
|
public static function getRandomInt($min = 0, $max = 999999) |
251
|
|
|
{ |
252
|
18 |
|
$val = null; |
253
|
|
|
|
254
|
18 |
|
if (function_exists('random_int')) { |
255
|
|
|
try { |
256
|
|
|
if (!isset($max)) { |
257
|
|
|
$max = PHP_INT_MAX; |
258
|
|
|
} |
259
|
|
|
$val = random_int($min, $max); |
260
|
|
|
} catch (Exception $e) { |
261
|
|
|
// eg if no crypto source is available |
262
|
|
|
$val = null; |
263
|
|
|
} |
264
|
|
|
} |
265
|
|
|
|
266
|
18 |
|
if (!isset($val)) { |
267
|
18 |
|
if (function_exists('mt_rand')) { |
268
|
18 |
|
$val = mt_rand($min, $max); |
269
|
18 |
|
} else { |
270
|
|
|
$val = rand($min, $max); |
271
|
|
|
} |
272
|
18 |
|
} |
273
|
18 |
|
return $val; |
274
|
|
|
} |
275
|
|
|
} |
276
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.