Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.95% covered (warning)
51.95%
40 / 77
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Heartbeat
51.95% covered (warning)
51.95%
40 / 77
36.36% covered (danger)
36.36%
4 / 11
129.86
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 cron_exec
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 generate_stats_array
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 jetpack_xmlrpc_methods
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 xmlrpc_data_response
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 deactivate
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 cli_callback
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 initialize_rest_api
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 rest_heartbeat_data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rest_heartbeat_data_permission_check
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2/**
3 * Jetpack Heartbeat package.
4 *
5 * @package  automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack;
9
10use Automattic\Jetpack\Connection\Rest_Authentication;
11use Automattic\Jetpack\Connection\REST_Connector;
12use Jetpack_Options;
13use WP_CLI;
14use WP_Error;
15use WP_REST_Request;
16use WP_REST_Server;
17
18/**
19 * Heartbeat sends a batch of stats to wp.com once a day
20 */
21class Heartbeat {
22
23    /**
24     * Holds the singleton instance of this class
25     *
26     * @since 1.0.0
27     * @since-jetpack 2.3.3
28     * @var Heartbeat
29     */
30    private static $instance = false;
31
32    /**
33     * Cronjob identifier
34     *
35     * @var string
36     */
37    private $cron_name = 'jetpack_v2_heartbeat';
38
39    /**
40     * Singleton
41     *
42     * @since 1.0.0
43     * @since-jetpack 2.3.3
44     * @static
45     * @return Heartbeat
46     */
47    public static function init() {
48        if ( ! self::$instance ) {
49            self::$instance = new Heartbeat();
50        }
51
52        return self::$instance;
53    }
54
55    /**
56     * Constructor for singleton
57     *
58     * @since 1.0.0
59     * @since-jetpack 2.3.3
60     */
61    private function __construct() {
62
63        // Schedule the task.
64        add_action( $this->cron_name, array( $this, 'cron_exec' ) );
65
66        if ( ! wp_next_scheduled( $this->cron_name ) ) {
67            // Deal with the old pre-3.0 weekly one.
68            $timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
69            if ( $timestamp ) {
70                wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
71            }
72
73            wp_schedule_event( time(), 'daily', $this->cron_name );
74        }
75
76        add_filter( 'jetpack_xmlrpc_unauthenticated_methods', array( __CLASS__, 'jetpack_xmlrpc_methods' ) );
77
78        if ( defined( 'WP_CLI' ) && WP_CLI ) {
79            WP_CLI::add_command( 'jetpack-heartbeat', array( $this, 'cli_callback' ) );
80        }
81
82        add_action( 'rest_api_init', array( $this, 'initialize_rest_api' ) );
83    }
84
85    /**
86     * Method that gets executed on the wp-cron call
87     *
88     * @since 1.0.0
89     * @since-jetpack 2.3.3
90     * @global string $wp_version
91     */
92    public function cron_exec() {
93
94        $a8c_mc_stats = new A8c_Mc_Stats();
95
96        /*
97         * This should run daily.  Figuring in for variances in
98         * WP_CRON, don't let it run more than every 23 hours at most.
99         *
100         * i.e. if it ran less than 23 hours ago, fail out.
101         */
102        $last = (int) Jetpack_Options::get_option( 'last_heartbeat' );
103        if ( $last && ( $last + DAY_IN_SECONDS - HOUR_IN_SECONDS > time() ) ) {
104            return;
105        }
106
107        /*
108         * Check for an identity crisis
109         *
110         * If one exists:
111         * - Bump stat for ID crisis
112         * - Email site admin about potential ID crisis
113         */
114
115        // Coming Soon!
116
117        foreach ( self::generate_stats_array( 'v2-' ) as $key => $value ) {
118            if ( is_array( $value ) ) {
119                foreach ( $value as $v ) {
120                    $a8c_mc_stats->add( $key, (string) $v );
121                }
122            } else {
123                $a8c_mc_stats->add( $key, (string) $value );
124            }
125        }
126
127        Jetpack_Options::update_option( 'last_heartbeat', time() );
128
129        $a8c_mc_stats->do_server_side_stats();
130
131        /**
132         * Fires when we synchronize all registered options on heartbeat.
133         *
134         * @since 3.3.0
135         */
136        do_action( 'jetpack_heartbeat' );
137    }
138
139    /**
140     * Generates heartbeat stats data.
141     *
142     * @param string $prefix Prefix to add before stats identifier.
143     *
144     * @return array The stats array.
145     */
146    public static function generate_stats_array( $prefix = '' ) {
147
148        /**
149         * This filter is used to build the array of stats that are bumped once a day by Jetpack Heartbeat.
150         *
151         * Filter the array and add key => value pairs where
152         * * key is the stat group name
153         * * value is the stat name.
154         *
155         * Example:
156         * add_filter( 'jetpack_heartbeat_stats_array', function( $stats ) {
157         *    $stats['is-https'] = is_ssl() ? 'https' : 'http';
158         * });
159         *
160         * This will bump the stats for the 'is-https/https' or 'is-https/http' stat.
161         *
162         * @param array  $stats The stats to be filtered.
163         * @param string $prefix The prefix that will automatically be added at the begining at each stat group name.
164         */
165        $stats  = apply_filters( 'jetpack_heartbeat_stats_array', array(), $prefix );
166        $return = array();
167
168        // Apply prefix to stats.
169        foreach ( $stats as $stat => $value ) {
170            $return[ "$prefix$stat" ] = $value;
171        }
172
173        return $return;
174    }
175
176    /**
177     * Registers jetpack.getHeartbeatData xmlrpc method
178     *
179     * @param array $methods The list of methods to be filtered.
180     * @return array $methods
181     */
182    public static function jetpack_xmlrpc_methods( $methods ) {
183        $methods['jetpack.getHeartbeatData'] = array( __CLASS__, 'xmlrpc_data_response' );
184        return $methods;
185    }
186
187    /**
188     * Handles the response for the jetpack.getHeartbeatData xmlrpc method
189     *
190     * @param array $params The parameters received in the request.
191     * @return array $params all the stats that heartbeat handles.
192     */
193    public static function xmlrpc_data_response( $params = array() ) {
194        // The WordPress XML-RPC server sets a default param of array()
195        // if no argument is passed on the request and the method handlers get this array in $params.
196        // generate_stats_array() needs a string as first argument.
197        $params = empty( $params ) ? '' : $params;
198        return self::generate_stats_array( $params );
199    }
200
201    /**
202     * Clear scheduled events
203     *
204     * @return void
205     */
206    public function deactivate() {
207        // Deal with the old pre-3.0 weekly one.
208        $timestamp = wp_next_scheduled( 'jetpack_heartbeat' );
209        if ( $timestamp ) {
210            wp_unschedule_event( $timestamp, 'jetpack_heartbeat' );
211        }
212
213        $timestamp = wp_next_scheduled( $this->cron_name );
214        wp_unschedule_event( $timestamp, $this->cron_name );
215    }
216
217    /**
218     * Interact with the Heartbeat
219     *
220     * ## OPTIONS
221     *
222     * inspect (default): Gets the list of data that is going to be sent in the heartbeat and the date/time of the last heartbeat
223     *
224     * @param array $args Arguments passed via CLI.
225     *
226     * @return void
227     */
228    public function cli_callback( $args ) {
229
230        $allowed_args = array(
231            'inspect',
232        );
233
234        if ( isset( $args[0] ) && ! in_array( $args[0], $allowed_args, true ) ) {
235            /* translators: %s is a command like "prompt" */
236            WP_CLI::error( sprintf( __( '%s is not a valid command.', 'jetpack-connection' ), $args[0] ) );
237        }
238
239        $stats           = self::generate_stats_array();
240        $formatted_stats = array();
241
242        foreach ( $stats as $stat_name => $bin ) {
243            $formatted_stats[] = array(
244                'Stat name' => $stat_name,
245                'Bin'       => $bin,
246            );
247        }
248
249        WP_CLI\Utils\format_items( 'table', $formatted_stats, array( 'Stat name', 'Bin' ) );
250
251        $last_heartbeat = Jetpack_Options::get_option( 'last_heartbeat' );
252
253        if ( $last_heartbeat ) {
254            $last_date = gmdate( 'Y-m-d H:i:s', $last_heartbeat );
255            /* translators: %s is the full datetime of the last heart beat e.g. 2020-01-01 12:21:23 */
256            WP_CLI::line( sprintf( __( 'Last heartbeat sent at: %s', 'jetpack-connection' ), $last_date ) );
257        }
258    }
259
260    /**
261     * Initialize the heartbeat REST API.
262     *
263     * @return void
264     */
265    public function initialize_rest_api() {
266        register_rest_route(
267            'jetpack/v4',
268            '/heartbeat/data',
269            array(
270                'methods'             => WP_REST_Server::READABLE,
271                'callback'            => array( $this, 'rest_heartbeat_data' ),
272                'permission_callback' => array( $this, 'rest_heartbeat_data_permission_check' ),
273                'args'                => array(
274                    'prefix' => array(
275                        'description' => __( 'Prefix to add before the stats identifiers.', 'jetpack-connection' ),
276                        'type'        => 'string',
277                    ),
278                ),
279            )
280        );
281    }
282
283    /**
284     * Endpoint to retrieve the heartbeat data.
285     *
286     * @param WP_REST_Request $request The request data.
287     *
288     * @since 2.7.0
289     *
290     * @return array
291     */
292    public function rest_heartbeat_data( WP_REST_Request $request ) {
293        return static::generate_stats_array( $request->get_param( 'prefix' ) );
294    }
295
296    /**
297     * Check permissions for the `get_heartbeat_data` endpoint.
298     *
299     * @return true|WP_Error
300     */
301    public function rest_heartbeat_data_permission_check() {
302        if ( current_user_can( 'jetpack_connect' ) ) {
303            return true;
304        }
305
306        return Rest_Authentication::is_signed_with_blog_token()
307            ? true
308            : new WP_Error( 'invalid_permission_heartbeat_data', REST_Connector::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
309    }
310}