Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.28% covered (danger)
46.28%
56 / 121
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Speed_Score_Request
46.22% covered (danger)
46.22%
55 / 119
50.00% covered (danger)
50.00%
7 / 14
191.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 generate_cache_id_from_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canonicalize_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_active_modules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 jsonUnserialize
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 cache_prefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 is_pending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_success
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 poll_update
47.06% covered (danger)
47.06%
16 / 34
0.00% covered (danger)
0.00%
0 / 1
17.50
 restart
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 record_history
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * Represents a request to generate a pair of speed scores.
4 *
5 * @package automattic/jetpack-boost-speed-score
6 */
7
8namespace Automattic\Jetpack\Boost_Speed_Score;
9
10use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
11use Automattic\Jetpack\Boost_Core\Lib\Cacheable;
12use Automattic\Jetpack\Boost_Core\Lib\Url;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * Class Speed_Score_Request
20 */
21class Speed_Score_Request extends Cacheable {
22    /**
23     * Algorithm to use when defining a hash for the cache.
24     */
25    const CACHE_KEY_HASH_ALGO = 'md5';
26
27    /**
28     * The URL to get the Speed Scores for.
29     *
30     * @var string $url Url to get the Speed Scores for.
31     */
32    private $url;
33
34    /**
35     * Active Jetpack Boost modules.
36     *
37     * @var string[] $active_modules Active modules.
38     */
39    private $active_modules;
40
41    /**
42     * When the Speed Scores request was created, in seconds since epoch.
43     *
44     * @var int|float $created Speed Scores request creation timestamp.
45     */
46    private $created;
47
48    /**
49     * Current status of the Speed Score request.
50     *
51     * @var string $status Speed Scores request status.
52     */
53    private $status;
54
55    /**
56     * Number of retries attempted.
57     *
58     * @var int $retry_count Number of times this Speed Score request has been retried.
59     */
60    private $retry_count;
61
62    /**
63     * The error returned
64     *
65     * @var array $error Speed Scores error.
66     */
67    private $error;
68
69    /**
70     * Where the Speed Scores request was made from.
71     *
72     * @var string $client A string passed to Speed_Score to identify where the request was made from.
73     */
74    private $client;
75
76    /**
77     * Constructor.
78     *
79     * @param string         $url The URL to get the Speed Scores for.
80     * @param array          $active_modules Active modules.
81     * @param int|float|null $created When the Speed Scores request was created, in seconds since epoch.
82     * @param string         $status Status of the Speed Scores request.
83     * @param null           $error The Speed Scores error.
84     * @param string         $client A string identifying where the request was made from.
85     */
86    public function __construct( $url, $active_modules = array(), $created = null, $status = 'pending', $error = null, $client = null ) {
87        $this->set_cache_id( self::generate_cache_id_from_url( $url ) );
88
89        $this->url            = $url;
90        $this->active_modules = $active_modules;
91        $this->created        = $created === null ? microtime( true ) : $created;
92        $this->status         = $status;
93        $this->error          = $error;
94        $this->client         = $client;
95        $this->retry_count    = 0;
96    }
97
98    /**
99     * Generate the cache ID from the URL.
100     *
101     * @param string $url The URL to get the Speed Scores for.
102     *
103     * @return string
104     */
105    public static function generate_cache_id_from_url( $url ) {
106        return hash( self::CACHE_KEY_HASH_ALGO, self::canonicalize_url( $url ) );
107    }
108
109    /**
110     * Remove trailing path slashes while preserving the query string and fragment.
111     *
112     * Url::normalize() is no use here: it also removes the query argument that keeps the
113     * "without Boost" scores apart from the regular ones.
114     *
115     * @param string $url The URL.
116     *
117     * @return string
118     */
119    private static function canonicalize_url( $url ) {
120        $path_length = strcspn( $url, '?#' );
121
122        return rtrim( substr( $url, 0, $path_length ), '/' ) . substr( $url, $path_length );
123    }
124
125    /**
126     * Get the list of active performance modules while this request was created.
127     *
128     * @return string[]
129     */
130    public function get_active_modules() {
131        return $this->active_modules;
132    }
133
134    /**
135     * Convert this object to a plain array for JSON serialization.
136     */
137    #[\ReturnTypeWillChange]
138    public function jsonSerialize() {
139        return array(
140            'id'             => $this->get_cache_id(),
141            'url'            => $this->url,
142            'active_modules' => $this->get_active_modules(),
143            'created'        => $this->created,
144            'status'         => $this->status,
145            'error'          => $this->error,
146            'client'         => $this->client,
147            'retry_count'    => $this->retry_count,
148        );
149    }
150
151    /**
152     * This is intended to be the reverse of JsonSerializable->jsonSerialize.
153     *
154     * @param mixed $data The data to turn into an object.
155     *
156     * @return Speed_Score_Request
157     */
158    public static function jsonUnserialize( $data ) {
159        $object = new Speed_Score_Request(
160            $data['url'],
161            $data['active_modules'],
162            $data['created'],
163            $data['status'],
164            $data['error'],
165            $data['client']
166        );
167
168        if ( ! empty( $data['id'] ) ) {
169            $object->set_cache_id( $data['id'] );
170        }
171
172        if ( ! empty( $data['retry_count'] ) ) {
173            $object->retry_count = intval( $data['retry_count'] );
174        }
175
176        return $object;
177    }
178
179    /**
180     * Return the cache prefix.
181     *
182     * @return string
183     */
184    protected static function cache_prefix() {
185        return 'jetpack_boost_speed_scores_';
186    }
187
188    /**
189     * Send a Speed Scores request to the API.
190     *
191     * @return true|\WP_Error True on success, WP_Error on failure.
192     */
193    public function execute() {
194        $response = Boost_API::post(
195            'speed-scores',
196            array(
197                'request_id'     => $this->get_cache_id(),
198                'url'            => Url::normalize( $this->url ),
199                'active_modules' => $this->active_modules,
200                'client'         => $this->client,
201            )
202        );
203
204        if ( is_wp_error( $response ) ) {
205            $this->status = 'error';
206            $this->error  = $response->get_error_message();
207            $this->store();
208
209            return $response;
210        }
211
212        return true;
213    }
214
215    /**
216     * Is this request pending?
217     */
218    public function is_pending() {
219        return 'pending' === $this->status;
220    }
221
222    /**
223     * Did the request fail?
224     */
225    public function is_error() {
226        return 'error' === $this->status;
227    }
228
229    /**
230     * Did the request succeed?
231     */
232    public function is_success() {
233        return 'success' === $this->status;
234    }
235
236    /**
237     * Poll for updates to this Speed Scores request.
238     *
239     * @return true|\WP_Error True on success, WP_Error on failure.
240     */
241    public function poll_update() {
242        $response = Boost_API::get(
243            sprintf(
244                'speed-scores/%s',
245                $this->get_cache_id()
246            )
247        );
248
249        if ( is_wp_error( $response ) ) {
250            // Special case: If the request is not found, restart it.
251            if ( 'not_found' === $response->get_error_code() ) {
252                return $this->restart();
253            }
254
255            return $response;
256        }
257
258        switch ( $response['status'] ) {
259            case 'pending':
260                // The initial job probably failed, dispatch again if so.
261                if ( $this->created <= strtotime( '-15 mins' ) ) {
262                    return $this->restart();
263                }
264                break;
265
266            case 'error':
267                $this->status = 'error';
268                $this->error  = $response['error'];
269                $this->store();
270                break;
271
272            case 'success':
273                $this->status = 'success';
274                $this->store();
275                $this->record_history( $response );
276
277                break;
278
279            default:
280                return new \WP_Error(
281                    'invalid_response',
282                    __(
283                        'Invalid response from WPCOM API while polling for speed scores',
284                        'jetpack-boost-speed-score'
285                    ),
286                    $response
287                );
288        }
289
290        return true;
291    }
292
293    /**
294     * Restart this request; useful when WPCOM doesn't recognize the request or it times out.
295     */
296    private function restart() {
297        // Enforce a maximum number of restarts.
298        if ( $this->retry_count > 2 ) {
299            $this->status = 'error';
300            $this->error  = 'Maximum number of retries exceeded';
301            $this->store();
302
303            return new \WP_Error(
304                'error',
305                $this->error
306            );
307        }
308
309        $result = $this->execute();
310        if ( is_wp_error( $result ) ) {
311            return $result;
312        }
313
314        $this->created = time();
315        ++$this->retry_count;
316        $this->store();
317
318        return true;
319    }
320
321    /**
322     * Save the speed score record to history.
323     *
324     * @param array $response Response from api.
325     */
326    private function record_history( $response ) {
327        $history       = new Speed_Score_History( $this->url );
328        $last_history  = $history->latest();
329        $last_scores   = $last_history ? $last_history['scores'] : null;
330        $last_theme    = $last_history['theme'] ?? null; // Entries saved before the theme was recorded have no theme key.
331        $current_theme = wp_get_theme()->get( 'Name' );
332
333        // Scores describe the site as it stood when the run was dispatched, so judge staleness
334        // against that instant.
335        $measured_current_site = $this->created >= Speed_Score_History::get_stale_timestamp();
336
337        // Only change if there is a difference from last score or the theme changed.
338        if ( $last_scores !== $response['scores'] || $current_theme !== $last_theme ) {
339            $history->push(
340                array(
341                    'timestamp' => $measured_current_site ? time() : (int) $this->created,
342                    'scores'    => $response['scores'],
343                    'theme'     => $current_theme,
344                )
345            );
346        } elseif ( $measured_current_site ) {
347            $history->touch_latest();
348        }
349    }
350}