PluginProbe
EasyTest – Simplify A/B Testing / trunk
EasyTest – Simplify A/B Testing vtrunk
1.0.4 1.0.3 trunk 1.0.1 1.0.2
convertpro / includes / Classes / Redirection.php

Redirection.php in EasyTest – Simplify A/B Testing trunk, at includes/Classes/Redirection.php

428 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace ConvertPro\Classes;
4 if (!defined('ABSPATH')) {
5 exit; // Called directly, nothing to do here.
6 }
7
8
9 class Redirection
10 {
11 function __construct()
12 {
13 add_action('template_redirect', [$this, 'random_redirect']);
14 add_action('template_redirect', [$this, 'update_conversion']);
15 }
16
17 // Function to randomly redirect users between two pages based on identifier
18
19 public function update_conversion()
20 {
21 // The page WordPress actually resolved for this request. Matching on this
22 // rather than on a permalink string is what makes query parameters and
23 // endpoints harmless, and it stops a 404 counting as a conversion —
24 // get_the_permalink() with nothing in the loop falls back to whichever
25 // post happens to come first.
26 $current_page_id = get_queried_object_id();
27
28 if (!$current_page_id) {
29 return;
30 }
31
32 global $wpdb;
33 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
34 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
35 $results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}convertpro WHERE test_type = 'pages' AND active = 1");
36 foreach ($results as $value) {
37
38 // Click goals are recorded by the front-end tracker instead. Skipping
39 // them here also avoids get_the_permalink(0) falling back to the
40 // current page, which would count every page as a conversion.
41 if ('click' === $value->conversion_type || empty($value->conversion_page_id)) {
42 continue;
43 }
44
45 // Nothing to do unless this is the goal page. Checked before the
46 // variation query so an ordinary page view costs no queries at all.
47 if ((int) $value->conversion_page_id !== $current_page_id) {
48 continue;
49 }
50
51 $run = convertpro_get_test_run($value->id);
52 $cookieName = convertpro_test_cookie_name('convert_pro_test_', $value->id);
53 $variationCookie = convertpro_test_cookie_name('convert_pro_variation_id_', $value->id);
54 $active_class = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName])) : '';
55
56 $variations = $this->convertpro_query($value->id); // Fetch all variations for the current test
57 foreach ($variations as $variation) {
58 $user_variation_id = isset($_COOKIE[$variationCookie]) ? sanitize_text_field(wp_unslash($_COOKIE[$variationCookie])) : '';
59 $client_id = isset($_COOKIE['convert_pro_uid']) ? sanitize_text_field(wp_unslash($_COOKIE['convert_pro_uid'])) : '';
60
61 // Without a visitor id there is no row to flip, and the update
62 // would match on client_id = '' instead.
63 if ($user_variation_id == $variation->id && !empty($active_class) && '' !== $client_id) {
64 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
65 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
66 $wpdb->update(
67 $wpdb->prefix . 'convertpro_interactions',
68 array('type' => 'conversion'),
69 array('variation_id' => $variation->id, 'splittest_id' => $value->id, 'client_id' => $client_id, 'run' => $run)
70 );
71 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
72 }
73 }
74 }
75 }
76 public function random_redirect()
77 {
78 // Check if the identifier exists in the URL query string
79 $current_slug = convertpro_current_path();
80
81 global $wpdb;
82
83 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
84 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
85 $results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}convertpro WHERE test_type = 'pages' AND active = 1");
86 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
87
88 foreach ($results as $value) {
89 // A row with no test URL matches the empty path, which is the home
90 // page — so a single blank row sent every visitor to a variation and
91 // the site looked like it had been taken over. Saving one has been
92 // refused since 1.0.3; this is for the rows already out there.
93 if ('' === trim((string) $value->test_uri, '/ ')) {
94 continue;
95 }
96
97 if ($current_slug === trim(wp_parse_url(home_url() . '/' . $value->test_uri, PHP_URL_PATH), '/')) {
98
99 // A test URL is an address of its own with nothing behind it, so
100 // WordPress should have nothing to show here. When it does have
101 // something, the row is one of the broken pre-1.0.3 ones and
102 // redirecting would hijack a page people are meant to read — or
103 // send them back to this same page forever, if the page is also
104 // one of the variations. Leave the request alone.
105 if (!is_404()) {
106 continue;
107 }
108
109 // This URL decides which variation the visitor gets, so it must
110 // not be cached — a stored copy would send everyone the same way.
111 // Only this one URL is affected; the variation pages themselves
112 // stay cacheable, because they are ordinary pages.
113 convertpro_prevent_page_cache();
114
115 $variations = $this->convertpro_query($value->id); // Fetch all variations for the current test
116
117 // Someone who has already been assigned goes back to the same
118 // variation. Their target comes from get_permalink() like a first
119 // visit does — the old home_url() . $slug only agreed with it for
120 // a top-level page on a plain permalink structure, so a child page
121 // sent returning visitors to a 404 and nobody else.
122 $assigned = $this->variationFromCookie($variations, $value->id);
123
124 if ($assigned) {
125 $this->redirectToVariation($assigned);
126 }
127
128 // Check remaining counts and update if needed
129 $needsUpdate = false;
130
131 $allRemainingZero = array_filter($variations, function ($obj) {
132 return $obj->remaining != 0;
133 });
134
135
136 if (empty($allRemainingZero)) {
137
138 $this->refillRemaining($variations);
139 $variations = $this->convertpro_query($value->id); // Fetch all variations for the current test
140
141 }
142
143
144 $cookieName = convertpro_test_cookie_name('convert_pro_test_', $value->id);
145 $client_test_id = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName])) : '';
146
147 if (!empty($client_test_id)) {
148 continue;
149 }
150
151 // Pick at random (weighted by the quota left) instead of taking the
152 // first variation with quota, so the variation a visitor gets is not
153 // correlated with when they arrived.
154 $variation = $this->selectVariation($variations);
155
156 if ($variation) {
157 $remaining = $variation->remaining - 1;
158 $this->updateVariationAndRedirect($wpdb, $variation, $cookieName, $value->id, $remaining);
159 }
160 }
161 }
162 }
163
164 /**
165 * The variation this visitor was already given, if any.
166 *
167 * @param array $variations Variation rows for a single test.
168 * @param int $test_id
169 * @return object|null
170 */
171 public function variationFromCookie($variations, $test_id)
172 {
173 $cookieName = convertpro_test_cookie_name('convert_pro_test_', $test_id);
174 $assigned_slug = isset($_COOKIE[$cookieName]) ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName])) : '';
175
176 if ('' === $assigned_slug) {
177 return null;
178 }
179
180 foreach ($variations as $variation) {
181 if ($variation->page_slug === $assigned_slug) {
182 return $variation;
183 }
184 }
185
186 return null;
187 }
188
189 /**
190 * Send the visitor to a variation, unless doing so would break the site.
191 *
192 * Returns instead of redirecting when the variation page has gone or when the
193 * target is the page being requested — the second is what "too many
194 * redirects" looked like on tests saved before 1.0.3. Letting the request
195 * render is always better than sending the browser round again.
196 *
197 * @param object $variation
198 * @return bool True when the visitor was redirected (execution ends there).
199 */
200 public function redirectToVariation($variation)
201 {
202 $target = get_permalink($variation->page_id);
203
204 if (!$target || convertpro_redirect_loops($target)) {
205 return false;
206 }
207
208 wp_redirect(convertpro_forward_query_string($target));
209 exit;
210 }
211
212 /**
213 * Pick one of the variations that still has quota left, at random and
214 * weighted by the remaining quota, so a 70/30 split really does hand out
215 * roughly 70/30 rather than serving all of A before any of B.
216 *
217 * @param array $variations Variation rows for a single test.
218 * @return object|null
219 */
220 public function selectVariation($variations)
221 {
222 $available_variations = array_values(array_filter((array) $variations, function ($variation) {
223 return $variation->remaining > 0;
224 }));
225
226 if (empty($available_variations)) {
227 return null;
228 }
229
230 $total = 0;
231 foreach ($available_variations as $variation) {
232 $total += (int) $variation->remaining;
233 }
234
235 if ($total <= 0) {
236 return $available_variations[array_rand($available_variations)];
237 }
238
239 $ticket = wp_rand(1, $total);
240 $cursor = 0;
241
242 foreach ($available_variations as $variation) {
243 $cursor += (int) $variation->remaining;
244 if ($ticket <= $cursor) {
245 return $variation;
246 }
247 }
248
249 return $available_variations[0];
250 }
251
252 public function convertpro_query($id)
253 {
254 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
255 global $wpdb;
256 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
257 $results = $wpdb->get_results(
258 $wpdb->prepare(
259 "SELECT v.*, p.post_name AS page_slug
260 FROM " . $wpdb->prefix . "convertpro_variations v
261 LEFT JOIN " . $wpdb->prefix . "posts p ON v.page_id = p.ID
262 WHERE v.splittest_id = %d
263 ORDER BY v.id ASC",
264 $id
265 )
266 );
267 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
268 return $results;
269 }
270
271 public function updateVariationAndRedirect($wpdb, $variation, $cookieName, $testid, $remaining)
272 {
273 $target = get_permalink($variation->page_id);
274
275 // Checked before anything is written: a test whose URL is also one of its
276 // own variation pages loops forever, and quota and cookies should not be
277 // spent on a visitor who is about to be left where they are.
278 if (!$target || convertpro_redirect_loops($target)) {
279 return false;
280 }
281
282 // Take one off the quota in the database rather than writing back the
283 // number this request read. Two visitors arriving together both read the
284 // same figure, and both writing it back means one of the two assignments
285 // is never deducted — the split drifts on exactly the busy site where it
286 // matters. $remaining is left in the signature but no longer used.
287 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
288 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
289 $wpdb->query(
290 $wpdb->prepare(
291 "UPDATE {$wpdb->prefix}convertpro_variations
292 SET remaining = remaining - 1
293 WHERE id = %d AND splittest_id = %d AND remaining > 0",
294 $variation->id,
295 $testid
296 )
297 );
298 $cookie_value = $this->convertpro_generateuid();
299 $expires = time() + CONVERTPRO_COOKIE_LIFETIME;
300
301 setcookie($cookieName, $variation->page_slug, $expires, '/');
302 setcookie('convert_pro_test_id', $testid, $expires, '/');
303 setcookie(convertpro_test_cookie_name('convert_pro_variation_id_', $testid), $variation->id, $expires, '/');
304
305 // The uid links this visitor to their interaction row, so it has to live
306 // as long as the assignment cookies — a shorter life silently drops every
307 // conversion that happens after it expires.
308 if (!isset($_COOKIE['convert_pro_uid'])) {
309 setcookie('convert_pro_uid', $cookie_value, $expires, '/');
310 $_COOKIE['convert_pro_uid'] = $cookie_value;
311 }
312 // store cookie value
313 $this->store_visit_data(sanitize_text_field(wp_unslash($_COOKIE['convert_pro_uid'])), $variation->id, $testid);
314
315 wp_redirect(convertpro_forward_query_string($target));
316 exit();
317 }
318
319 public function updateVariationRemaining($wpdb, $variationId, $remainingPercentage)
320 {
321 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
322 $wpdb->query(
323 $wpdb->prepare(
324 "UPDATE {$wpdb->prefix}convertpro_variations
325 SET remaining = %d
326 WHERE id = %d",
327 $remainingPercentage,
328 $variationId
329 )
330 );
331 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
332 }
333
334 // Function to generate UUID
335 public function convertpro_generateuid()
336 {
337 return sprintf(
338 '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
339
340 // 32 bits for "time_low"
341 wp_rand(0, 0xffff),
342 wp_rand(0, 0xffff),
343
344 // 16 bits for "time_mid"
345 wp_rand(0, 0xffff),
346
347 // 16 bits for "time_hi_and_version",
348 // four most significant bits holds version number 4
349 wp_rand(0, 0x0fff) | 0x4000,
350
351 // 16 bits, 8 bits for "clk_seq_hi_res",
352 // 8 bits for "clk_seq_low",
353 // two most significant bits holds zero and one for variant DCE1.1
354 wp_rand(0, 0x3fff) | 0x8000,
355
356 // 48 bits for "node"
357 wp_rand(0, 0xffff),
358 wp_rand(0, 0xffff),
359 wp_rand(0, 0xffff)
360 );
361 }
362
363 public function store_visit_data($cookie_value, $variation, $testid)
364 {
365 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
366 global $wpdb;
367 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
368 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
369 $run = convertpro_get_test_run($testid);
370
371 $query = $wpdb->get_results($wpdb->prepare(
372 "SELECT * FROM {$wpdb->prefix}convertpro_interactions
373 WHERE splittest_id = %d
374 AND client_id = %s
375 AND run = %d",
376 $testid,
377 $cookie_value,
378 $run
379 ), OBJECT);
380
381 if (sizeof($query) == 0) {
382 $table_name = $wpdb->prefix . 'convertpro_interactions';
383 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
384 // created_at is deliberately left to the column default so it comes
385 // from the same clock as updated_at and as NOW() in the report
386 // queries. Writing it from PHP would use the site's timezone, which
387 // need not agree with the database server's. Installs created before
388 // the schema carried that default are repaired on upgrade — see
389 // ConvertPro::maybe_upgrade().
390 $wpdb->insert(
391 $table_name,
392 array(
393 'client_id' => $cookie_value,
394 'splittest_id' => $testid,
395 'variation_id' => $variation,
396 'run' => $run,
397 )
398 );
399 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
400 }
401 }
402
403
404 public function refillRemaining($variations)
405 {
406 global $wpdb;
407 $results = [];
408 // var_dump($variations);
409 foreach ($variations as $variation) {
410
411 // Refill with the full configured percentage. Taking only the first
412 // digit (50 -> 5) both shrank the cycle and distorted every split
413 // that was not a round multiple of ten (25/75 became 2:7).
414 $percentage = (int) $variation->percentage;
415
416 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
417 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
418 $results = $wpdb->update(
419 $wpdb->prefix . 'convertpro_variations',
420 array('remaining' => (int) $percentage),
421 array('id' => (int) $variation->id)
422 );
423 }
424
425 return $results;
426 }
427 }
428