Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.60% covered (warning)
88.60%
101 / 114
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scheduled_Updates_Logs
88.60% covered (warning)
88.60%
101 / 114
75.00% covered (warning)
75.00%
6 / 8
33.52
0.00% covered (danger)
0.00%
0 / 1
 log
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 get
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 clear
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 infer_status_from_logs
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
9.00
 replace_logs_schedule_id
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 delete_logs_schedule_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add_log_fields
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 split_logs_into_runs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Scheduled Updates Logs
4 *
5 * @package automattic/scheduled-updates
6 */
7
8namespace Automattic\Jetpack;
9
10/**
11 * Scheduled_Update_Logs class
12 *
13 * This class provides a static methods to log/retrieve logs for scheduled updates.
14 */
15class Scheduled_Updates_Logs {
16    /**
17     * The name of the WordPress option where the logs are stored.
18     */
19    const OPTION_NAME = 'jetpack_scheduled_update_logs';
20
21    /**
22     * The maximum number of runs to keep per schedule_id.
23     */
24    const MAX_RUNS_PER_SCHEDULE = 2;
25
26    /**
27     * Action constant representing the different kind of log message.
28     */
29    const PLUGIN_UPDATES_START                    = 'PLUGIN_UPDATES_START';
30    const PLUGIN_UPDATES_SUCCESS                  = 'PLUGIN_UPDATES_SUCCESS';
31    const PLUGIN_UPDATES_FAILURE                  = 'PLUGIN_UPDATES_FAILURE';
32    const PLUGIN_UPDATE_SUCCESS                   = 'PLUGIN_UPDATE_SUCCESS';
33    const PLUGIN_UPDATE_FAILURE                   = 'PLUGIN_UPDATE_FAILURE';
34    const PLUGIN_SITE_HEALTH_CHECK_SUCCESS        = 'PLUGIN_SITE_HEALTH_CHECK_SUCCESS';
35    const PLUGIN_SITE_HEALTH_CHECK_FAILURE        = 'PLUGIN_SITE_HEALTH_CHECK_FAILURE';
36    const PLUGIN_UPDATE_FAILURE_AND_ROLLBACK      = 'PLUGIN_UPDATE_FAILURE_AND_ROLLBACK';
37    const PLUGIN_UPDATE_FAILURE_AND_ROLLBACK_FAIL = 'PLUGIN_UPDATE_FAILURE_AND_ROLLBACK_FAIL';
38
39    const ENUM_ACTIONS = array(
40        self::PLUGIN_UPDATES_START,
41        self::PLUGIN_UPDATES_SUCCESS,
42        self::PLUGIN_UPDATES_FAILURE,
43        self::PLUGIN_UPDATE_SUCCESS,
44        self::PLUGIN_UPDATE_FAILURE,
45        self::PLUGIN_SITE_HEALTH_CHECK_SUCCESS,
46        self::PLUGIN_SITE_HEALTH_CHECK_FAILURE,
47        self::PLUGIN_UPDATE_FAILURE_AND_ROLLBACK,
48        self::PLUGIN_UPDATE_FAILURE_AND_ROLLBACK_FAIL,
49    );
50
51    /**
52     * Logs a scheduled update event.
53     *
54     * @param string $schedule_id The ID of the schedule.
55     * @param string $action      The action constant representing the event.
56     * @param string $message     Optional. The message associated with the event.
57     * @param mixed  $context     Optional. Additional context data associated with the event.
58     * @param int    $timestamp   Optional. The Unix timestamp of the log entry. Default is the current time.
59     * @return bool True if the log was successfully saved, false otherwise.
60     */
61    public static function log( $schedule_id, $action, $message = null, $context = null, $timestamp = null ) {
62        $events = wp_get_scheduled_events( Scheduled_Updates::PLUGIN_CRON_HOOK );
63        if ( ! isset( $events[ $schedule_id ] ) ) {
64            return false;
65        }
66
67        if ( null === $timestamp ) {
68            $timestamp = wp_date( 'U' );
69        }
70
71        $log_entry = array(
72            'timestamp' => intval( $timestamp ),
73            'action'    => $action,
74            'message'   => $message,
75            'context'   => $context,
76        );
77
78        $logs = get_option( self::OPTION_NAME, array() );
79
80        if ( ! isset( $logs[ $schedule_id ] ) ) {
81            $logs[ $schedule_id ] = array();
82        }
83
84        $logs[ $schedule_id ][] = $log_entry;
85
86        // Keep only the logs for the last MAX_RUNS_PER_SCHEDULE runs per schedule_id.
87        $start_count   = 0;
88        $last_two_runs = array();
89        for ( $i = count( $logs[ $schedule_id ] ) - 1; $i >= 0; $i-- ) {
90            if ( self::PLUGIN_UPDATES_START === $logs[ $schedule_id ][ $i ]['action'] ) {
91                ++$start_count;
92            }
93            $last_two_runs[] = $logs[ $schedule_id ][ $i ];
94            if ( self::MAX_RUNS_PER_SCHEDULE === $start_count ) {
95                break;
96            }
97        }
98        $last_two_runs        = array_reverse( $last_two_runs );
99        $logs[ $schedule_id ] = $last_two_runs;
100
101        return update_option( self::OPTION_NAME, $logs );
102    }
103
104    /**
105     * Retrieves the logs for a specific schedule_id or all logs if no schedule_id is provided.
106     *
107     * If a schedule_id is provided, the logs for that specific schedule are returned.
108     * If no schedule_id is provided, all logs are returned, with each schedule_id as a key in the array.
109     *
110     * @param string|null $schedule_id Optional. The ID of the schedule. If not provided, all logs will be returned.
111     * @return array {
112     *      An array containing the logs, split by run.
113     *      Each run is an array of log entries, where each log entry is an associative array containing the following keys:
114     *
115     *      @type int         $timestamp The Unix timestamp of the log entry.
116     *      @type string      $action    The action constant representing the event.
117     *      @type string|null $message   The message associated with the event, if available.
118     *      @type mixed|null  $context   Additional context data associated with the event, if available.
119     * }
120     */
121    public static function get( $schedule_id = null ) {
122        $logs = get_option( self::OPTION_NAME, array() );
123
124        if ( null === $schedule_id ) {
125            // Return all logs if no schedule_id is provided.
126            $all_logs = array();
127            foreach ( $logs as $schedule_id => $schedule_logs ) {
128                $all_logs[ $schedule_id ] = self::split_logs_into_runs( $schedule_logs );
129            }
130            return $all_logs;
131        }
132
133        if ( ! isset( $logs[ $schedule_id ] ) ) {
134            return array();
135        }
136
137        $schedule_logs = $logs[ $schedule_id ];
138        return self::split_logs_into_runs( $schedule_logs );
139    }
140
141    /**
142     * Clears the logs for a specific schedule_id or all logs if no schedule_id is provided.
143     *
144     * @param string|null $schedule_id Optional. The ID of the schedule. If not provided, all logs will be cleared.
145     */
146    public static function clear( ?string $schedule_id = null ) {
147        $logs = get_option( self::OPTION_NAME, array() );
148
149        if ( null === $schedule_id ) {
150            // Clear all logs if no schedule_id is provided.
151            $logs = array();
152        } else {
153            // Clear the logs for the specific schedule_id.
154            unset( $logs[ $schedule_id ] );
155        }
156
157        update_option( self::OPTION_NAME, $logs );
158    }
159
160    /**
161     * Infers the status of a plugin update schedule from its logs.
162     *
163     * @param string $schedule_id The ID of the plugin update schedule.
164     *
165     * @return array|false An array containing the last run timestamp and status, or false if no logs are found.
166     *                     The array has the following keys:
167     *                     - 'last_run_timestamp': The timestamp of the last run, or null if the status is 'in-progress'.
168     *                     - 'last_run_status': The status of the last run, which can be one of the following:
169     *                       - 'in-progress': The update is currently in progress.
170     *                       - 'success': The update was successful.
171     *                       - 'failure': The update failed.
172     *                       - 'failure-and-rollback': The update failed and a rollback was performed.
173     *                       - 'failure-and-rollback-fail': The update failed and the rollback also failed.
174     */
175    public static function infer_status_from_logs( $schedule_id ) {
176        $logs = self::get( $schedule_id );
177        if ( empty( $logs ) ) {
178            return false;
179        }
180
181        $last_run = end( $logs );
182
183        $status    = 'in-progress';
184        $timestamp = time();
185
186        foreach ( $last_run as $log_entry ) {
187            $timestamp = $log_entry['timestamp'];
188
189            if ( self::PLUGIN_UPDATES_SUCCESS === $log_entry['action'] ) {
190                $status = 'success';
191                break;
192            }
193            if ( self::PLUGIN_UPDATES_FAILURE === $log_entry['action'] ) {
194                $status = 'failure';
195                break;
196            }
197            if ( self::PLUGIN_UPDATE_FAILURE_AND_ROLLBACK === $log_entry['action'] ) {
198                $status = 'failure-and-rollback';
199                break;
200            }
201            if ( self::PLUGIN_UPDATE_FAILURE_AND_ROLLBACK_FAIL === $log_entry['action'] ) {
202                $status = 'failure-and-rollback-fail';
203                break;
204            }
205        }
206
207        return array(
208            'last_run_timestamp' => 'in-progress' === $status ? null : $timestamp,
209            'last_run_status'    => $status,
210        );
211    }
212
213    /**
214     * Replaces the logs with the old schedule ID with new ones.
215     *
216     * @param string $old_schedule_id The old schedule ID.
217     * @param string $new_schedule_id The new schedule ID.
218     */
219    public static function replace_logs_schedule_id( $old_schedule_id, $new_schedule_id ) {
220        if ( $old_schedule_id === $new_schedule_id ) {
221            return;
222        }
223
224        $logs = get_option( self::OPTION_NAME, array() );
225
226        if ( isset( $logs[ $old_schedule_id ] ) ) {
227            // Replace the logs with the old schedule ID with new ones.
228            $logs[ $new_schedule_id ] = $logs[ $old_schedule_id ];
229            unset( $logs[ $old_schedule_id ] );
230
231            update_option( self::OPTION_NAME, $logs );
232        }
233    }
234
235    /**
236     * Deletes the logs for a schedule ID when the current request is a DELETE request.
237     *
238     * @param string           $schedule_id The ID of the schedule to delete.
239     * @param object           $event       The deleted event object.
240     * @param \WP_REST_Request $request     The request object.
241     */
242    public static function delete_logs_schedule_id( $schedule_id, $event, $request ) {
243        if ( $request->get_method() === \WP_REST_Server::DELETABLE ) {
244            self::clear( $schedule_id );
245        }
246    }
247
248    /**
249     * Registers the last_run_timestamp field for the update-schedule REST API.
250     */
251    public static function add_log_fields() {
252        register_rest_field(
253            'update-schedule',
254            'last_run_timestamp',
255            array(
256                /**
257                 * Populates the last_run_timestamp field.
258                 *
259                 * @param array $item Prepared response array.
260                 * @return int|null
261                 */
262                'get_callback' => function ( $item ) {
263                    $status = static::infer_status_from_logs( $item['schedule_id'] );
264
265                    return $status['last_run_timestamp'] ?? null;
266                },
267                'schema'       => array(
268                    'description' => 'Unix timestamp (UTC) for when the last run occurred.',
269                    'type'        => 'integer',
270                ),
271            )
272        );
273
274        register_rest_field(
275            'update-schedule',
276            'last_run_status',
277            array(
278                /**
279                 * Populates the last_run_status field.
280                 *
281                 * @param array $item Prepared response array.
282                 * @return string|null
283                 */
284                'get_callback' => function ( $item ) {
285                    $status = static::infer_status_from_logs( $item['schedule_id'] );
286
287                    return $status['last_run_status'] ?? null;
288                },
289                'schema'       => array(
290                    'description' => 'Status of last run.',
291                    'type'        => 'string',
292                    'enum'        => array( 'success', 'failure-and-rollback', 'failure-and-rollback-fail' ),
293                ),
294            )
295        );
296    }
297
298    /**
299     * Splits the logs into runs based on the PLUGIN_UPDATES_START action.
300     *
301     * @param array $logs The logs to split into runs.
302     *
303     * @return array An array containing the logs split into runs.
304     */
305    private static function split_logs_into_runs( $logs ) {
306        $runs        = array();
307        $current_run = array();
308
309        foreach ( $logs as $log_entry ) {
310            if ( self::PLUGIN_UPDATES_START === $log_entry['action'] ) {
311                if ( ! empty( $current_run ) ) {
312                    $runs[] = $current_run;
313                }
314                $current_run = array();
315            }
316            $current_run[] = $log_entry;
317        }
318
319        if ( ! empty( $current_run ) ) {
320            $runs[] = $current_run;
321        }
322
323        return $runs;
324    }
325}