Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.41% covered (warning)
66.41%
85 / 128
71.88% covered (warning)
71.88%
23 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Stats
66.41% covered (warning)
66.41%
85 / 128
71.88% covered (warning)
71.88%
23 / 32
169.68
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_stats
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_stats_summary
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_top_posts
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_archives
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_video_details
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_referrers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_clicks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_tags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_top_authors
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_top_comments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_video_plays
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_file_downloads
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_post_views
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_views_by_country
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_views_by_location
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_followers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_comment_followers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_publicize_followers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_search_terms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_total_post_views
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 get_visits
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_streak
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_highlights
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_insights
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 build_endpoint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fetch_stats
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 fetch_post_stats
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 refresh_post_stats_cache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 fetch_remote_stats
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
56
 fetch_stats_on_wpcom_simple
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 convert_stats_array_to_object
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Stats WPCOM_Stats
4 *
5 * @package automattic/jetpack-stats
6 */
7
8namespace Automattic\Jetpack\Stats;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Status\Host;
12use Jetpack_Options;
13use WP_Error;
14
15/**
16 * Stats WPCOM_Stats class.
17 *
18 * Responsible for fetching Stats related data from WPCOM.
19 *
20 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
21 *
22 * @since 0.1.0
23 */
24class WPCOM_Stats {
25    /**
26     * Transient prefix for storing Stats results from the REST API.
27     *
28     * @var string
29     */
30    const STATS_CACHE_TRANSIENT_PREFIX = 'jetpack_restapi_stats_cache_';
31
32    /**
33     * Time, in minutes, to cache stats results from the REST API.
34     *
35     * @var int
36     */
37    const STATS_CACHE_EXPIRATION_IN_MINUTES = 5;
38
39    /**
40     * Stats REST API version.
41     *
42     * @var string
43     */
44    const STATS_REST_API_VERSION = '1.1';
45
46    /**
47     * The stats resource to fetch results for.
48     *
49     * @var string
50     */
51    protected $resource;
52
53    /**
54     * If the site is on WPCOM Simple.
55     *
56     * @var bool
57     */
58    protected $is_wpcom_simple;
59
60    /**
61     * The constructor.
62     */
63    public function __construct() {
64        $this->is_wpcom_simple = ( new Host() )->is_wpcom_simple();
65    }
66
67    /**
68     * Get site's stats.
69     *
70     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
71     * @param array $args Optional query parameters.
72     * @return array| WP_Error
73     */
74    public function get_stats( $args = array() ) {
75        $this->resource = '';
76
77        return $this->fetch_stats( $args );
78    }
79
80    /**
81     * Get site's summarized views, visitors, likes and comments.
82     *
83     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/summary/
84     * @param array $args Optional query parameters.
85     * @return array|WP_Error
86     */
87    public function get_stats_summary( $args = array() ) {
88        $this->resource = 'summary';
89
90        return $this->fetch_stats( $args );
91    }
92
93    /**
94     * Get site's top posts and pages by views.
95     *
96     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-posts/
97     * @param array $args Optional query parameters.
98     * @param bool  $override_cache Optional override cache.
99     * @return array|WP_Error
100     */
101    public function get_top_posts( $args = array(), $override_cache = false ) {
102        $this->resource = 'top-posts';
103
104        // Needed for the Top Posts block, so users can preview changes instantly.
105        if ( $override_cache ) {
106            return $this->fetch_remote_stats( $this->build_endpoint(), $args );
107        }
108
109        return $this->fetch_stats( $args );
110    }
111
112    /**
113     * Get site's archive pages by views.
114     *
115     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/archives/
116     * @param array $args Optional query parameters.
117     * @return array|WP_Error
118     */
119    public function get_archives( $args = array() ) {
120        $this->resource = 'archives';
121
122        return $this->fetch_stats( $args );
123    }
124
125    /**
126     * Get the details of a single video.
127     *
128     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video/%24post_id/
129     * @param int   $post_id The video's ID.
130     * @param array $args    Optional query parameters.
131     * @return array|WP_Error
132     */
133    public function get_video_details( $post_id, $args = array() ) {
134        $this->resource = sprintf( 'video/%d', $post_id );
135
136        return $this->fetch_stats( $args );
137    }
138
139    /**
140     * Get site's referrers.
141     *
142     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/referrers/
143     * @param array $args Optional query parameters.
144     * @return array|WP_Error
145     */
146    public function get_referrers( $args = array() ) {
147        $this->resource = 'referrers';
148
149        return $this->fetch_stats( $args );
150    }
151
152    /**
153     * Get site's outbound clicks.
154     *
155     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/clicks/
156     * @param array $args Optional query parameters.
157     * @return array|WP_Error
158     */
159    public function get_clicks( $args = array() ) {
160        $this->resource = 'clicks';
161
162        return $this->fetch_stats( $args );
163    }
164
165    /**
166     * Get site's views by tags and categories.
167     *
168     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/tags/
169     * @param array $args Optional query parameters.
170     * @return array|WP_Error
171     */
172    public function get_tags( $args = array() ) {
173        $this->resource = 'tags';
174
175        return $this->fetch_stats( $args );
176    }
177
178    /**
179     * Get site's top authors.
180     *
181     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-authors/
182     * @param array $args Optional query parameters.
183     * @return array|WP_Error
184     */
185    public function get_top_authors( $args = array() ) {
186        $this->resource = 'top-authors';
187
188        return $this->fetch_stats( $args );
189    }
190
191    /**
192     * Get site's top comment authors and most-commented posts.
193     *
194     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/comments/
195     * @param array $args Optional query parameters.
196     * @return array|WP_Error
197     */
198    public function get_top_comments( $args = array() ) {
199        $this->resource = 'comments';
200
201        return $this->fetch_stats( $args );
202    }
203
204    /**
205     * Get site's video plays.
206     *
207     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video-plays/
208     * @param array $args Optional query parameters.
209     * @return array|WP_Error
210     */
211    public function get_video_plays( $args = array() ) {
212        $this->resource = 'video-plays';
213
214        return $this->fetch_stats( $args );
215    }
216
217    /**
218     * Get site's file downloads.
219     *
220     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/file-downloads/
221     * @param array $args Optional query parameters.
222     * @return array|WP_Error
223     */
224    public function get_file_downloads( $args = array() ) {
225        $this->resource = 'file-downloads';
226
227        return $this->fetch_stats( $args );
228    }
229
230    /**
231     * Get a post's views.
232     *
233     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/post/%24post_id/
234     * @param int   $post_id        The post's ID.
235     * @param array $args           Optional query parameters.
236     * @param bool  $cache_in_meta  Optional should cache in post meta.
237     * @return array|WP_Error
238     */
239    public function get_post_views( $post_id, $args = array(), $cache_in_meta = false ) {
240        $this->resource = sprintf( 'post/%d', $post_id );
241
242        if ( $cache_in_meta ) {
243            return $this->fetch_post_stats( $args, $post_id );
244        }
245
246        return $this->fetch_stats( $args );
247    }
248
249    /**
250     * Get site's views by country.
251     *
252     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/country-views/
253     * @param array $args Optional query parameters.
254     * @return array|WP_Error
255     */
256    public function get_views_by_country( $args = array() ) {
257
258        $this->resource = 'country-views';
259
260        return $this->fetch_stats( $args );
261    }
262
263    /**
264     * Get site's views by location.
265     *
266     * @param string $geo_mode The type of location to fetch views for (country, region, city).
267     * @param array  $args     Optional query parameters.
268     * @return array|WP_Error
269     */
270    public function get_views_by_location( $geo_mode, $args = array() ) {
271        $this->resource = sprintf( 'location-views/%s', $geo_mode );
272
273        return $this->fetch_stats( $args );
274    }
275
276    /**
277     * Get site's followers.
278     *
279     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/followers/
280     * @param array $args Optional query parameters.
281     * @return array|WP_Error
282     */
283    public function get_followers( $args = array() ) {
284
285        $this->resource = 'followers';
286
287        return $this->fetch_stats( $args );
288    }
289
290    /**
291     * Get site's comment followers.
292     *
293     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/comment-followers/
294     * @param array $args Optional query parameters.
295     * @return array|WP_Error
296     */
297    public function get_comment_followers( $args = array() ) {
298
299        $this->resource = 'comment-followers';
300
301        return $this->fetch_stats( $args );
302    }
303
304    /**
305     * Get site's publicize follower counts.
306     *
307     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/publicize/
308     * @param array $args Optional query parameters.
309     * @return array|WP_Error
310     */
311    public function get_publicize_followers( $args = array() ) {
312
313        $this->resource = 'publicize';
314
315        return $this->fetch_stats( $args );
316    }
317
318    /**
319     * Get search terms used to find the site.
320     *
321     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/search-terms/
322     * @param array $args Optional query parameters.
323     * @return array|WP_Error
324     */
325    public function get_search_terms( $args = array() ) {
326
327        $this->resource = 'search-terms';
328
329        return $this->fetch_stats( $args );
330    }
331
332    /**
333     * Get the total number of views for each post.
334     *
335     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/views/posts/
336     * @param array $args Optional query parameters.
337     * @return array|WP_Error
338     */
339    public function get_total_post_views( $args = array() ) {
340        if ( $this->is_wpcom_simple ) {
341            $post_ids         = isset( $args['post_ids'] ) ? explode( ',', $args['post_ids'] ) : array();
342            $escaped_post_ids = implode( ',', array_map( 'esc_sql', $post_ids ) );
343
344            $number_of_days = isset( $args['num'] ) ? absint( $args['num'] ) : 1;
345            // It's the same function used in WPCOM simple.
346            // @phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
347            $end_date = $args['end'] ?? date( 'Y-m-d' );
348
349            $stats = $this->fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids );
350
351            $post_views = $stats['-'] ?? array();
352
353            $posts = array_map(
354                function ( $post_id ) use ( $post_views ) {
355                    return array(
356                        'ID'    => $post_id,
357                        'views' => $post_views[ $post_id ] ?? 0,
358                    );
359                },
360                $post_ids
361            );
362
363            return array( 'posts' => $posts );
364        }
365
366        $this->resource = 'views/posts';
367
368        return $this->fetch_stats( $args );
369    }
370
371    /**
372     * Get the number of visits for the site.
373     *
374     * @param array $args Optional query parameters.
375     * @return array|WP_Error
376     */
377    public function get_visits( $args = array() ) {
378
379        $this->resource = 'visits';
380
381        return $this->fetch_stats( $args );
382    }
383
384    /**
385     * Get streaks for the site.
386     *
387     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/streak/
388     *
389     * @param array $args Optional query parameters.
390     * @return array|WP_Error
391     */
392    public function get_streak( $args = array() ) {
393
394        $this->resource = 'streak';
395
396        return $this->fetch_stats( $args );
397    }
398
399    /**
400     * Get the highlights for the site.
401     *
402     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/highlights/
403     *
404     * @param array $args Optional query parameters.
405     * @return array|WP_Error
406     */
407    public function get_highlights( $args = array() ) {
408
409        $this->resource = 'highlights';
410
411        return $this->fetch_stats( $args );
412    }
413
414    /**
415     * Get the number of visits for the site.
416     *
417     * @param array $args Optional query parameters.
418     * @return array|WP_Error
419     */
420    public function get_insights( $args = array() ) {
421
422        $this->resource = 'insights';
423
424        return $this->fetch_stats( $args );
425    }
426
427    /**
428     * Build WPCOM REST API endpoint.
429     *
430     * @return string
431     */
432    protected function build_endpoint() {
433        $resource = ltrim( $this->resource, '/' );
434
435        return sprintf( '/sites/%d/stats/%s', Jetpack_Options::get_option( 'id' ), $resource );
436    }
437
438    /**
439     * Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes.
440     *
441     * @param array $args Optional query parameters.
442     *
443     * @return array|WP_Error
444     */
445    protected function fetch_stats( $args = array() ) {
446        $endpoint       = $this->build_endpoint();
447        $api_version    = self::STATS_REST_API_VERSION;
448        $cache_key      = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ) ) ) );
449        $transient_name = self::STATS_CACHE_TRANSIENT_PREFIX . $cache_key;
450        $stats_cache    = get_transient( $transient_name );
451
452        if ( $stats_cache ) {
453            $time = key( $stats_cache );
454            $data = $stats_cache[ $time ]; // WP_Error or string (JSON encoded object).
455
456            if ( is_wp_error( $data ) ) {
457                return $data;
458            }
459
460            return array_merge( array( 'cached_at' => $time ), (array) json_decode( $data, true ) );
461        }
462
463        $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
464
465        // To reduce size in storage: store with time as key, store JSON encoded data.
466        $cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats, JSON_UNESCAPED_SLASHES );
467
468        /**
469         * Filters the expiration time for the stats cache.
470         *
471         * @module stats
472         *
473         * @since 0.10.0
474         *
475         * @param int $expiration The expiration time in minutes.
476         */
477        $expiration = apply_filters(
478            'jetpack_fetch_stats_cache_expiration',
479            self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
480        );
481        set_transient( $transient_name, array( time() => $cached_value ), $expiration );
482
483        return $wpcom_stats;
484    }
485
486    /**
487     * Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes.
488     *
489     * Unlike the above function, this caches data in the post meta table. As such,
490     * it prevents wp_options from blowing up when retrieving views for large numbers
491     * of posts at the same time.
492     *
493     * This function returns valid arrays and WP_Error objects from cache if within the expiration period.
494     * If the cached entry is malformed or invalid, a refresh is triggered regardless of cache time.
495     * This self-healing behavior reduces API calls when remote fetch fails, but ensures data validity.
496     *
497     * @param array $args Query parameters.
498     * @param int   $post_id Post ID to acquire stats for.
499     *
500     * @return array|WP_Error
501     */
502    protected function fetch_post_stats( $args, $post_id ) {
503        $endpoint    = $this->build_endpoint();
504        $meta_name   = '_' . self::STATS_CACHE_TRANSIENT_PREFIX;
505        $stats_cache = get_post_meta( $post_id, $meta_name, false );
506
507        if ( $stats_cache ) {
508            $data = reset( $stats_cache );
509
510            // Check if we have a valid cache structure with a time key.
511            if ( is_array( $data ) && ! empty( $data ) ) {
512                $time = key( $data );
513
514                // If we have a numeric time, check if cache is still valid.
515                if ( is_numeric( $time ) ) {
516                    /** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
517                    $expiration = apply_filters(
518                        'jetpack_fetch_stats_cache_expiration',
519                        self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
520                    );
521
522                    // If within cache period, return cached data after type validation.
523                    if ( ( time() - $time ) < $expiration ) {
524                        $cached_value = $data[ $time ];
525
526                        // If it's an array or WP_Error, handle appropriately.
527                        if ( is_wp_error( $cached_value ) ) {
528                            return $cached_value;
529                        }
530                        if ( is_array( $cached_value ) ) {
531                            return array_merge( array( 'cached_at' => $time ), $cached_value );
532                        }
533
534                        // For any other unexpected type, treat as malformed cache.
535                        // Fall through to refresh.
536                    }
537                }
538            }
539        }
540
541        // Cache doesn't exist, is expired, or is malformed - refresh it.
542        return $this->refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name );
543    }
544
545    /**
546     * Force fetch stats from WPCOM, and always update cache.
547     *
548     * This function will cache the result regardless of whether the fetch succeeds
549     * or fails. This ensures that failed requests are also cached, reducing the
550     * frequency of API calls when the remote service is experiencing issues.
551     *
552     * @param string $endpoint The stats endpoint.
553     * @param array  $args The query arguments.
554     * @param int    $post_id The post ID.
555     * @param string $meta_name The meta name.
556     *
557     * @return array|WP_Error
558     */
559    protected function refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ) {
560        $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
561
562        // Always cache the result, even if it's an error or empty.
563        update_post_meta( $post_id, $meta_name, array( time() => $wpcom_stats ) );
564
565        return $wpcom_stats;
566    }
567
568    /**
569     * Fetches stats data from WPCOM.
570     *
571     * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
572     * @param string $endpoint The stats endpoint.
573     * @param array  $args The query arguments.
574     * @return array|WP_Error
575     */
576    protected function fetch_remote_stats( $endpoint, $args ) {
577        if ( is_array( $args ) && ! empty( $args ) ) {
578            $endpoint .= '?' . http_build_query( $args );
579        }
580        $response      = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION, array( 'timeout' => 20 ) );
581        $response_code = wp_remote_retrieve_response_code( $response );
582        $response_body = wp_remote_retrieve_body( $response );
583
584        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response_body ) ) {
585            return is_wp_error( $response ) ? $response : new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );
586        }
587
588        return json_decode( $response_body, true );
589    }
590
591    /**
592     * Fetch the stats when executed in WPCOM Simple.
593     *
594     * @param string $end_date         The end date.
595     * @param int    $number_of_days   The number of days.
596     * @param string $escaped_post_ids The escaped post ids.
597     *
598     * @return array
599     */
600    protected function fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids ) {
601        return stats_get_daily_history( null, get_current_blog_id(), 'postviews', 'post_id', $end_date, $number_of_days, " AND post_id IN ($escaped_post_ids)", 0, true );
602    }
603
604    /**
605     * Convert stats array to object after sanity checking the array is valid.
606     *
607     * @since 0.11.0
608     *
609     * @param  array $stats_array The stats array.
610     * @return WP_Error|object|null
611     */
612    public function convert_stats_array_to_object( $stats_array ) {
613
614        if ( is_wp_error( $stats_array ) ) {
615            return $stats_array;
616        }
617        $encoded_array = wp_json_encode( $stats_array, JSON_UNESCAPED_SLASHES );
618        if ( ! $encoded_array ) {
619            return new WP_Error( 'stats_encoding_error', 'Failed to encode stats array' );
620        }
621        return json_decode( $encoded_array );
622    }
623}