Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.33% covered (success)
97.33%
73 / 75
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Speed_Score_History
97.33% covered (success)
97.33%
73 / 75
84.62% covered (warning)
84.62%
11 / 13
32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 maybe_migrate_legacy_records
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 migrate_legacy_record
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 get_latest_timestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_option_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 latest
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 latest_scores
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_stale_timestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mark_stale
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_stale
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 push
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 touch_latest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Speed score history after Jetpack Boost
4 *
5 * @package automattic/jetpack-boost-speed-score
6 */
7
8namespace Automattic\Jetpack\Boost_Speed_Score;
9
10use Automattic\Jetpack\Boost_Core\Lib\Transient;
11
12/**
13 * Class Speed_Score_History
14 *
15 * @package Automattic\Jetpack\Boost_Speed_Score\Lib
16 */
17class Speed_Score_History {
18    const OPTION_PREFIX       = 'jetpack_boost_speed_score_history_';
19    const STALE_TRANSIENT_KEY = 'speed_score_stale_marker';
20
21    /**
22     * Mark the raw home and site URL migration as complete. Autoloaded, so checking it costs no query.
23     */
24    const LEGACY_MIGRATED_OPTION = 'jetpack_boost_speed_score_legacy_migrated';
25
26    /**
27     * Limit the number of recent records to keep.
28     */
29    const LIMIT = 20;
30
31    /**
32     * The list of history entries.
33     *
34     * @var array
35     */
36    private $entries;
37
38    /**
39     * URL of the site the speed score belongs to.
40     *
41     * @var string
42     */
43    private $url;
44
45    /**
46     * Speed_Score_History constructor.
47     *
48     * @param string $url URL of the site the speed scores belong to.
49     */
50    public function __construct( $url ) {
51        self::maybe_migrate_legacy_records();
52
53        $this->url     = $url;
54        $this->entries = get_option( $this->get_option_name(), array() );
55
56        if ( empty( $this->entries ) ) {
57            $migrated_option = $this->get_option_name() . '_legacy_migrated';
58            if ( get_option( $migrated_option ) ) {
59                return;
60            }
61
62            $path_length = strcspn( $url, '?#' );
63            $base_url    = rtrim( substr( $url, 0, $path_length ), '/' );
64            $suffix      = substr( $url, $path_length );
65            $legacy_urls = array( $base_url . '/' . $suffix );
66
67            if ( $base_url === rtrim( home_url(), '/' ) ) {
68                $site_url = rtrim( get_site_url(), '/' );
69                if ( $site_url !== $base_url && set_url_scheme( $site_url, 'https' ) !== rtrim( set_url_scheme( get_option( 'home' ), 'https' ), '/' ) ) {
70                    $legacy_urls[] = $site_url . $suffix;
71                    $legacy_urls[] = $site_url . '/' . $suffix;
72                }
73            }
74
75            self::migrate_legacy_record( $url, $legacy_urls );
76            $this->entries = get_option( $this->get_option_name(), array() );
77            update_option( $migrated_option, 1, false );
78        }
79    }
80
81    /**
82     * Migrate older spellings of the raw home and site URLs once per site.
83     *
84     * The marker is set last, so a request arriving midway repeats the work rather than finding no
85     * scores and starting a new test.
86     */
87    private static function maybe_migrate_legacy_records() {
88        if ( get_option( self::LEGACY_MIGRATED_OPTION ) ) {
89            return;
90        }
91
92        // The raw options, because a translation plugin's home_url filter would migrate only that language's record.
93        // The first build can run under cron, where the home URL can have another scheme than in the admin.
94        foreach ( array( 'http', 'https' ) as $scheme ) {
95            $home_url = rtrim( set_url_scheme( get_option( 'home' ), $scheme ), '/' );
96
97            // My Jetpack saved the home page's scores under the site URL, which differs when WordPress lives in a subdirectory.
98            // Match schemes first: under cron or CLI the two can differ by scheme alone, which isn't a subdirectory install.
99            $site_url = rtrim( set_url_scheme( get_option( 'siteurl' ), $scheme ), '/' );
100
101            foreach ( array( '', '?jb-disable-modules=all' ) as $query ) {
102                $legacy_urls = array( $home_url . '/' . $query );
103                if ( $site_url !== $home_url ) {
104                    $legacy_urls[] = $site_url . $query;
105                    $legacy_urls[] = $site_url . '/' . $query;
106                }
107
108                self::migrate_legacy_record( $home_url . $query, $legacy_urls );
109            }
110        }
111
112        update_option( self::LEGACY_MIGRATED_OPTION, 1, true );
113    }
114
115    /**
116     * Move the newest of the older records to the given URL's key, unless its own record is newer.
117     *
118     * The canonical spelling is the one My Jetpack used to write to, so it can hold scores older
119     * than the ones the Boost dashboard showed.
120     *
121     * @param string   $url         The URL as it is keyed now.
122     * @param string[] $legacy_urls The spellings its scores may have been saved under.
123     */
124    private static function migrate_legacy_record( $url, $legacy_urls ) {
125        $newest_entries      = array();
126        $legacy_option_names = array();
127
128        foreach ( $legacy_urls as $legacy_url ) {
129            $option_name = static::OPTION_PREFIX . hash( Speed_Score_Request::CACHE_KEY_HASH_ALGO, $legacy_url );
130            $entries     = get_option( $option_name, array() );
131
132            if ( empty( $entries ) ) {
133                continue;
134            }
135
136            if ( self::get_latest_timestamp( $entries ) > self::get_latest_timestamp( $newest_entries ) ) {
137                $newest_entries = $entries;
138            }
139            $legacy_option_names[] = $option_name;
140        }
141
142        if ( empty( $legacy_option_names ) ) {
143            return;
144        }
145
146        $option_name = static::OPTION_PREFIX . Speed_Score_Request::generate_cache_id_from_url( $url );
147        if ( self::get_latest_timestamp( $newest_entries ) > self::get_latest_timestamp( get_option( $option_name, array() ) ) ) {
148            update_option( $option_name, $newest_entries, false );
149        }
150
151        // Do not reorder: a request that finds these gone must also find the record they moved to.
152        foreach ( $legacy_option_names as $legacy_option_name ) {
153            delete_option( $legacy_option_name );
154        }
155    }
156
157    /**
158     * Get the timestamp of the last of the given history entries.
159     *
160     * @param array $entries History entries, oldest first.
161     *
162     * @return int
163     */
164    private static function get_latest_timestamp( $entries ) {
165        $latest = end( $entries );
166
167        return (int) ( $latest['timestamp'] ?? 0 );
168    }
169
170    /**
171     * Determine the option_name used to store the speed score data in options table.
172     *
173     * @return string
174     */
175    private function get_option_name() {
176        return static::OPTION_PREFIX . Speed_Score_Request::generate_cache_id_from_url( $this->url );
177    }
178
179    /**
180     * Get the number of history records currently stored.
181     *
182     * @return int
183     */
184    public function count() {
185        return count( $this->entries );
186    }
187
188    /**
189     * Find the latest available speed score history record.
190     *
191     * @param int $offset Instead of receiving the last one, you can use offset to receive a slightly older speed score.
192     *
193     * @return array|null
194     */
195    public function latest( $offset = 0 ) {
196        $index = $this->count() - ( $offset + 1 );
197
198        if ( $index >= 0 ) {
199            return $this->entries[ $index ];
200        }
201
202        return null;
203    }
204
205    /**
206     * Find the latest available speed scores.
207     *
208     * @param int $offset Instead of receiving the last one, you can use offset to receive a slightly older speed score.
209     *
210     * @return array|null
211     */
212    public function latest_scores( $offset = 0 ) {
213        $index = $this->count() - ( $offset + 1 );
214
215        if ( $index >= 0 ) {
216            return $this->entries[ $index ]['scores'];
217        }
218
219        return null;
220    }
221
222    /**
223     * Get a timestamp that marks previous history stale.
224     *
225     * All speed score before this timestamp are considered stale.
226     *
227     * @return int
228     */
229    public static function get_stale_timestamp() {
230        $last_stale_marker = Transient::get( static::STALE_TRANSIENT_KEY, 0 );
231
232        // Any score that is older than 24 hours or before the last stale marker is considered stale.
233        return max( $last_stale_marker, time() - DAY_IN_SECONDS );
234    }
235
236    /**
237     * Mark previous speed score as stale.
238     *
239     * If a there were significant changes to the site, we want to mark prior speed scores as stale.
240     */
241    public static function mark_stale() {
242        Transient::set( static::STALE_TRANSIENT_KEY, time() );
243    }
244
245    /**
246     * Check if the last item in history is stale.
247     */
248    public function is_stale() {
249        $last_entry = $this->latest();
250
251        if ( ! $last_entry ) {
252            return true;
253        }
254
255        return $last_entry['timestamp'] < self::get_stale_timestamp();
256    }
257
258    /**
259     * Record a new history entry for speed scores.
260     *
261     * @param array $entry The new entry to save.
262     */
263    public function push( $entry ) {
264        $this->entries[] = $entry;
265        $this->entries   = array_slice( $this->entries, - static::LIMIT );
266        update_option( $this->get_option_name(), $this->entries, false );
267    }
268
269    /**
270     * Move the latest entry's timestamp to now without changing its scores.
271     *
272     * Used when a run completes with the same scores as before: no new entry is pushed, but
273     * staleness is measured by the timestamp, so it has to move or repeating scores look stale
274     * forever.
275     *
276     * @since 0.6.0
277     */
278    public function touch_latest() {
279        $index = $this->count() - 1;
280
281        if ( $index < 0 ) {
282            return;
283        }
284
285        $this->entries[ $index ]['timestamp'] = time();
286        update_option( $this->get_option_name(), $this->entries, false );
287    }
288}