Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.00% covered (danger)
50.00%
23 / 46
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Server_Assignment
50.00% covered (danger)
50.00%
23 / 46
50.00% covered (danger)
50.00%
2 / 4
38.50
0.00% covered (danger)
0.00%
0 / 1
 get_variation
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 fetch_variation
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 fetch_simple_variation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 get_cache_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Server-side ExPlat assignments.
4 *
5 * @package automattic/jetpack-explat
6 */
7
8namespace Automattic\Jetpack\ExPlat;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Status\Host;
13
14/**
15 * Reads an experiment's variation for the current user from PHP.
16 *
17 * Simple sites read from the ExPlat engine that ships with WordPress.com; Atomic sites
18 * ask WordPress.com as the connected user. Either way the answer is cached per user, and
19 * only once ExPlat has actually given one.
20 */
21class Server_Assignment {
22
23    /**
24     * ExPlat API version used for the assignments endpoint.
25     *
26     * @var string
27     */
28    const API_VERSION = '0.1.0';
29
30    /**
31     * The current user's variation, or null when there is no answer.
32     *
33     * Pass 'assign' => true when the call itself is the exposure, i.e. the visitor is
34     * about to see the thing being tested. Leave it false to read an existing
35     * assignment without creating one.
36     *
37     * @param string $experiment_name The experiment to read.
38     * @param array  $args            'platform' ('wpcom', 'calypso' or 'jetpack'), 'assign' (bool), 'ttl' (seconds),
39     *                                'is_user_connected' (callable), and 'request' (callable).
40     * @return string|null
41     */
42    public static function get_variation( $experiment_name, $args = array() ) {
43        $args = array_merge(
44            array(
45                'platform'          => 'wpcom',
46                'assign'            => false,
47                'ttl'               => HOUR_IN_SECONDS,
48                'is_user_connected' => null,
49                'request'           => null,
50            ),
51            $args
52        );
53
54        $user_id = get_current_user_id();
55        if ( ! $user_id ) {
56            return null;
57        }
58
59        $cache_key = self::get_cache_key( $experiment_name, $user_id, $args['platform'] );
60        $cached    = get_transient( $cache_key );
61        if ( is_string( $cached ) ) {
62            return $cached;
63        }
64
65        $variation = static::fetch_variation( $experiment_name, $args );
66
67        // No answer is not an answer: caching it would hold the user out of the
68        // experiment for the whole TTL over one failed request.
69        if ( null === $variation ) {
70            return null;
71        }
72
73        set_transient( $cache_key, $variation, $args['ttl'] );
74
75        return $variation;
76    }
77
78    /**
79     * Asks ExPlat for the variation, without caching.
80     *
81     * @param string $experiment_name The experiment to read.
82     * @param array  $args            As passed to get_variation().
83     * @return string|null
84     */
85    protected static function fetch_variation( $experiment_name, $args ) {
86        if ( ( new Host() )->is_wpcom_simple() ) {
87            return self::fetch_simple_variation( $experiment_name, (bool) $args['assign'] );
88        }
89
90        $is_user_connected = $args['is_user_connected'] ?? array( new Connection_Manager(), 'is_user_connected' );
91        if ( ! call_user_func( $is_user_connected ) ) {
92            return null;
93        }
94
95        $request_path = '/experiments/' . self::API_VERSION . '/assignments/' . $args['platform'];
96        $request      = $args['request'] ?? array( Client::class, 'wpcom_json_api_request_as_user' );
97        $response     = call_user_func(
98            $request,
99            add_query_arg( array( 'experiment_names' => $experiment_name ), $request_path ),
100            'v2'
101        );
102
103        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
104            return null;
105        }
106
107        $data = json_decode( wp_remote_retrieve_body( $response ), true );
108
109        return $data['variations'][ $experiment_name ] ?? null;
110    }
111
112    /**
113     * Reads the variation from the ExPlat engine on a Simple site.
114     *
115     * @param string $experiment_name The experiment to read.
116     * @param bool   $assign          Whether to create an assignment when none exists.
117     * @return string|null
118     */
119    private static function fetch_simple_variation( $experiment_name, $assign ) {
120        // The \ExPlat\ helpers live in WordPress.com, outside this monorepo.
121        if ( $assign ) {
122            if ( ! function_exists( '\ExPlat\assign_current_user' ) ) {
123                return null;
124            }
125
126            return \ExPlat\assign_current_user( $experiment_name );
127        }
128
129        if ( ! function_exists( '\ExPlat\get_current_user_assignment' ) ) {
130            return null;
131        }
132
133        // @phan-suppress-next-line PhanUndeclaredFunction -- Missing from .phan/stubs/wpcom-stubs.php.
134        return \ExPlat\get_current_user_assignment( $experiment_name );
135    }
136
137    /**
138     * Transient key for one user's assignment.
139     *
140     * @param string $experiment_name The experiment.
141     * @param int    $user_id         The user.
142     * @param string $platform        The ExPlat platform.
143     * @return string
144     */
145    private static function get_cache_key( $experiment_name, $user_id, $platform ) {
146        // Hashed: experiment names are long enough to overrun the option name column.
147        return 'jetpack-explat-' . $platform . '-' . $user_id . '-' . md5( $experiment_name );
148    }
149}