Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.31% covered (warning)
62.31%
81 / 130
75.00% covered (warning)
75.00%
12 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Speed_Score_Request
62.50% covered (warning)
62.50%
80 / 128
75.00% covered (warning)
75.00%
12 / 16
99.60
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
 get_created
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_poll_gap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 jsonUnserialize
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_success
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 poll_update
57.89% covered (warning)
57.89%
22 / 38
0.00% covered (danger)
0.00%
0 / 1
12.78
 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     * When this request was last polled, in seconds since epoch.
64     *
65     * @var int|float|null $last_polled Last poll timestamp, or null before the first poll.
66     */
67    private $last_polled;
68
69    /**
70     * Seconds between this poll and the one before it (or the dispatch), set by poll_update().
71     *
72     * @var float|null $poll_gap Gap in seconds, or null when not polled in this request.
73     */
74    private $poll_gap;
75
76    /**
77     * The error returned
78     *
79     * @var array $error Speed Scores error.
80     */
81    private $error;
82
83    /**
84     * Where the Speed Scores request was made from.
85     *
86     * @var string $client A string passed to Speed_Score to identify where the request was made from.
87     */
88    private $client;
89
90    /**
91     * Constructor.
92     *
93     * @param string         $url The URL to get the Speed Scores for.
94     * @param array          $active_modules Active modules.
95     * @param int|float|null $created When the Speed Scores request was created, in seconds since epoch.
96     * @param string         $status Status of the Speed Scores request.
97     * @param null           $error The Speed Scores error.
98     * @param string         $client A string identifying where the request was made from.
99     */
100    public function __construct( $url, $active_modules = array(), $created = null, $status = 'pending', $error = null, $client = null ) {
101        $this->set_cache_id( self::generate_cache_id_from_url( $url ) );
102
103        $this->url            = $url;
104        $this->active_modules = $active_modules;
105        $this->created        = $created === null ? microtime( true ) : $created;
106        $this->status         = $status;
107        $this->error          = $error;
108        $this->client         = $client;
109        $this->retry_count    = 0;
110    }
111
112    /**
113     * Generate the cache ID from the URL.
114     *
115     * @param string $url The URL to get the Speed Scores for.
116     *
117     * @return string
118     */
119    public static function generate_cache_id_from_url( $url ) {
120        return hash( self::CACHE_KEY_HASH_ALGO, self::canonicalize_url( $url ) );
121    }
122
123    /**
124     * Remove trailing path slashes while preserving the query string and fragment.
125     *
126     * Url::normalize() is no use here: it also removes the query argument that keeps the
127     * "without Boost" scores apart from the regular ones.
128     *
129     * @param string $url The URL.
130     *
131     * @return string
132     */
133    private static function canonicalize_url( $url ) {
134        $path_length = strcspn( $url, '?#' );
135
136        return rtrim( substr( $url, 0, $path_length ), '/' ) . substr( $url, $path_length );
137    }
138
139    /**
140     * Get the list of active performance modules while this request was created.
141     *
142     * @return string[]
143     */
144    public function get_active_modules() {
145        return $this->active_modules;
146    }
147
148    /**
149     * When this request was dispatched, or last restarted.
150     *
151     * @return int|float Seconds since epoch.
152     */
153    public function get_created() {
154        return $this->created;
155    }
156
157    /**
158     * Seconds between the latest poll and the one before it, or null when not polled in this request.
159     *
160     * @return float|null
161     */
162    public function get_poll_gap() {
163        return $this->poll_gap;
164    }
165
166    /**
167     * Convert this object to a plain array for JSON serialization.
168     */
169    #[\ReturnTypeWillChange]
170    public function jsonSerialize() {
171        return array(
172            'id'             => $this->get_cache_id(),
173            'url'            => $this->url,
174            'active_modules' => $this->get_active_modules(),
175            'created'        => $this->created,
176            'status'         => $this->status,
177            'error'          => $this->error,
178            'client'         => $this->client,
179            'retry_count'    => $this->retry_count,
180            'last_polled'    => $this->last_polled,
181        );
182    }
183
184    /**
185     * This is intended to be the reverse of JsonSerializable->jsonSerialize.
186     *
187     * @param mixed $data The data to turn into an object.
188     *
189     * @return Speed_Score_Request
190     */
191    public static function jsonUnserialize( $data ) {
192        $object = new Speed_Score_Request(
193            $data['url'],
194            $data['active_modules'],
195            $data['created'],
196            $data['status'],
197            $data['error'],
198            $data['client']
199        );
200
201        if ( ! empty( $data['id'] ) ) {
202            $object->set_cache_id( $data['id'] );
203        }
204
205        if ( ! empty( $data['retry_count'] ) ) {
206            $object->retry_count = intval( $data['retry_count'] );
207        }
208
209        if ( ! empty( $data['last_polled'] ) ) {
210            $object->last_polled = $data['last_polled'];
211        }
212
213        return $object;
214    }
215
216    /**
217     * Return the cache prefix.
218     *
219     * @return string
220     */
221    protected static function cache_prefix() {
222        return 'jetpack_boost_speed_scores_';
223    }
224
225    /**
226     * Send a Speed Scores request to the API.
227     *
228     * @return true|\WP_Error True on success, WP_Error on failure.
229     */
230    public function execute() {
231        $response = Boost_API::post(
232            'speed-scores',
233            array(
234                'request_id'     => $this->get_cache_id(),
235                'url'            => Url::normalize( $this->url ),
236                'active_modules' => $this->active_modules,
237                'client'         => $this->client,
238            )
239        );
240
241        if ( is_wp_error( $response ) ) {
242            $this->status = 'error';
243            $this->error  = $response->get_error_message();
244            $this->store();
245
246            return $response;
247        }
248
249        return true;
250    }
251
252    /**
253     * Is this request pending?
254     */
255    public function is_pending() {
256        return 'pending' === $this->status;
257    }
258
259    /**
260     * Did the request fail?
261     */
262    public function is_error() {
263        return 'error' === $this->status;
264    }
265
266    /**
267     * Did the request succeed?
268     */
269    public function is_success() {
270        return 'success' === $this->status;
271    }
272
273    /**
274     * Poll for updates to this Speed Scores request.
275     *
276     * @return true|\WP_Error True on success, WP_Error on failure.
277     */
278    public function poll_update() {
279        $now               = microtime( true );
280        $this->poll_gap    = $now - ( $this->last_polled ?? $this->created );
281        $this->last_polled = $now;
282
283        $response = Boost_API::get(
284            sprintf(
285                'speed-scores/%s',
286                $this->get_cache_id()
287            )
288        );
289
290        if ( is_wp_error( $response ) ) {
291            // Special case: If the request is not found, restart it.
292            if ( 'not_found' === $response->get_error_code() ) {
293                return $this->restart();
294            }
295
296            return $response;
297        }
298
299        switch ( $response['status'] ) {
300            case 'pending':
301                // The initial job probably failed, dispatch again if so.
302                if ( $this->created <= strtotime( '-15 mins' ) ) {
303                    return $this->restart();
304                }
305                // As long as WordPress.com keeps the result; the default would drop a run the user left mid-way.
306                $this->store( 3600 );
307                break;
308
309            case 'error':
310                $this->status = 'error';
311                $this->error  = $response['error'];
312                $this->store();
313                break;
314
315            case 'success':
316                $this->status = 'success';
317                $this->store();
318                $this->record_history( $response );
319
320                break;
321
322            default:
323                return new \WP_Error(
324                    'invalid_response',
325                    __(
326                        'Invalid response from WPCOM API while polling for speed scores',
327                        'jetpack-boost-speed-score'
328                    ),
329                    $response
330                );
331        }
332
333        return true;
334    }
335
336    /**
337     * Restart this request; useful when WPCOM doesn't recognize the request or it times out.
338     */
339    private function restart() {
340        // Enforce a maximum number of restarts.
341        if ( $this->retry_count > 2 ) {
342            $this->status = 'error';
343            $this->error  = 'Maximum number of retries exceeded';
344            $this->store();
345
346            return new \WP_Error(
347                'error',
348                $this->error
349            );
350        }
351
352        $result = $this->execute();
353        if ( is_wp_error( $result ) ) {
354            return $result;
355        }
356
357        $this->created = time();
358        ++$this->retry_count;
359        $this->store();
360
361        return true;
362    }
363
364    /**
365     * Save the speed score record to history.
366     *
367     * @param array $response Response from api.
368     */
369    private function record_history( $response ) {
370        $history       = new Speed_Score_History( $this->url );
371        $last_history  = $history->latest();
372        $last_scores   = $last_history ? $last_history['scores'] : null;
373        $last_theme    = $last_history['theme'] ?? null; // Entries saved before the theme was recorded have no theme key.
374        $current_theme = wp_get_theme()->get( 'Name' );
375
376        // Scores describe the site as it stood when the run was dispatched, so judge staleness
377        // against that instant.
378        $measured_current_site = $this->created >= Speed_Score_History::get_stale_timestamp();
379
380        // Only change if there is a difference from last score or the theme changed.
381        if ( $last_scores !== $response['scores'] || $current_theme !== $last_theme ) {
382            $history->push(
383                array(
384                    'timestamp' => $measured_current_site ? time() : (int) $this->created,
385                    'scores'    => $response['scores'],
386                    'theme'     => $current_theme,
387                )
388            );
389        } elseif ( $measured_current_site ) {
390            $history->touch_latest();
391        }
392    }
393}