PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-stats / src / class-wpcom-stats.php

class-wpcom-stats.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at jetpack_vendor/automattic/jetpack-stats/src/class-wpcom-stats.php

682 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stats WPCOM_Stats
4 *
5 * @package automattic/jetpack-stats
6 */
7
8 namespace Automattic\Jetpack\Stats;
9
10 use Automattic\Jetpack\Connection\Client;
11 use Automattic\Jetpack\Status\Host;
12 use Jetpack_Options;
13 use 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 */
24 class 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'] ) ? array_map( 'absint', explode( ',', $args['post_ids'] ) ) : array();
342 $escaped_post_ids = implode( ',', $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 = $this->should_bypass_cache() ? false : 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 /*
466 * Connection failures stop being true as soon as the site reconnects. Do not cache
467 * them: otherwise the next request keeps returning the old failure after recovery.
468 *
469 * `no_possible_tokens` is what the connection package reports for a missing blog token
470 * since it started naming the reason; `missing_token` is what older versions still return.
471 */
472 if ( is_wp_error( $wpcom_stats ) && in_array( $wpcom_stats->get_error_code(), array( 'missing_token', 'no_possible_tokens', 'site_not_connected' ), true ) ) {
473 return $wpcom_stats;
474 }
475
476 // To reduce size in storage: store with time as key, store JSON encoded data.
477 $cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats, JSON_UNESCAPED_SLASHES );
478
479 /**
480 * Filters the expiration time for the stats cache.
481 *
482 * @module stats
483 *
484 * @since 0.10.0
485 *
486 * @param int $expiration The expiration time in minutes.
487 */
488 $expiration = apply_filters(
489 'jetpack_fetch_stats_cache_expiration',
490 self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
491 );
492 set_transient( $transient_name, array( time() => $cached_value ), $expiration );
493
494 return $wpcom_stats;
495 }
496
497 /**
498 * Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes.
499 *
500 * Unlike the above function, this caches data in the post meta table. As such,
501 * it prevents wp_options from blowing up when retrieving views for large numbers
502 * of posts at the same time.
503 *
504 * This function returns valid arrays and WP_Error objects from cache if within the expiration period.
505 * If the cached entry is malformed or invalid, a refresh is triggered regardless of cache time.
506 * This self-healing behavior reduces API calls when remote fetch fails, but ensures data validity.
507 *
508 * @param array $args Query parameters.
509 * @param int $post_id Post ID to acquire stats for.
510 *
511 * @return array|WP_Error
512 */
513 protected function fetch_post_stats( $args, $post_id ) {
514 $endpoint = $this->build_endpoint();
515 $meta_name = '_' . self::STATS_CACHE_TRANSIENT_PREFIX;
516 $stats_cache = get_post_meta( $post_id, $meta_name, false );
517
518 if ( $stats_cache ) {
519 $data = reset( $stats_cache );
520
521 // Check if we have a valid cache structure with a time key.
522 if ( is_array( $data ) && ! empty( $data ) ) {
523 $time = key( $data );
524
525 // If we have a numeric time, check if cache is still valid.
526 if ( is_numeric( $time ) ) {
527 /** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
528 $expiration = apply_filters(
529 'jetpack_fetch_stats_cache_expiration',
530 self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
531 );
532
533 // If within cache period, return cached data after type validation.
534 if ( ( time() - $time ) < $expiration ) {
535 $cached_value = $data[ $time ];
536
537 // If it's an array or WP_Error, handle appropriately.
538 if ( is_wp_error( $cached_value ) ) {
539 return $cached_value;
540 }
541 if ( is_array( $cached_value ) ) {
542 return array_merge( array( 'cached_at' => $time ), $cached_value );
543 }
544
545 // For any other unexpected type, treat as malformed cache.
546 // Fall through to refresh.
547 }
548 }
549 }
550 }
551
552 // Cache doesn't exist, is expired, or is malformed - refresh it.
553 return $this->refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name );
554 }
555
556 /**
557 * Force fetch stats from WPCOM, and always update cache.
558 *
559 * This function will cache the result regardless of whether the fetch succeeds
560 * or fails. This ensures that failed requests are also cached, reducing the
561 * frequency of API calls when the remote service is experiencing issues.
562 *
563 * @param string $endpoint The stats endpoint.
564 * @param array $args The query arguments.
565 * @param int $post_id The post ID.
566 * @param string $meta_name The meta name.
567 *
568 * @return array|WP_Error
569 */
570 protected function refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ) {
571 $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
572
573 // Always cache the result, even if it's an error or empty.
574 update_post_meta( $post_id, $meta_name, array( time() => $wpcom_stats ) );
575
576 return $wpcom_stats;
577 }
578
579 /**
580 * Whether the caller has asked for an answer newer than the cached one.
581 *
582 * A site that has just connected, or just bought a plan, carries answers from before it did
583 * -- including failures, which are cached like any other answer. Both markers are the ones
584 * `Stats_Admin\WPCOM_Client` already honours, so a page load clears every layer or none.
585 *
586 * The dashboard asks for its data over REST, and those requests carry none of the page's
587 * query, so the marker has to be read from the page that sent them as well.
588 *
589 * Limited to users who can view stats: `fetch_stats()` also serves public widgets and
590 * blocks, and those must not skip the cache because a visitor supplied a query arg or a
591 * Referer that happens to contain one of the markers.
592 *
593 * @return bool
594 */
595 protected function should_bypass_cache() {
596 if ( ! current_user_can( 'view_stats' ) ) {
597 return false;
598 }
599
600 foreach ( array( 'force_refresh', 'statsPurchaseSuccess' ) as $marker ) {
601 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
602 if ( isset( $_GET[ $marker ] ) ) {
603 return true;
604 }
605
606 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
607 if ( isset( $_SERVER['HTTP_REFERER'] ) && false !== strpos( (string) $_SERVER['HTTP_REFERER'], $marker ) ) {
608 return true;
609 }
610 }
611
612 return false;
613 }
614
615 /**
616 * Fetches stats data from WPCOM.
617 *
618 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
619 * @param string $endpoint The stats endpoint.
620 * @param array $args The query arguments.
621 * @return array|WP_Error
622 */
623 protected function fetch_remote_stats( $endpoint, $args ) {
624 if ( is_array( $args ) && ! empty( $args ) ) {
625 $endpoint .= '?' . http_build_query( $args );
626 }
627 $response = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION, array( 'timeout' => 20 ) );
628 $response_code = wp_remote_retrieve_response_code( $response );
629 $response_body = wp_remote_retrieve_body( $response );
630 $data = json_decode( $response_body, true );
631 $error_code = is_wp_error( $response ) ? $response->get_error_code() : ( $data['error'] ?? $data['code'] ?? null );
632
633 // Traffic endpoints must expose the same connection errors as the Stats admin API client.
634 if ( in_array( $error_code, array( 'missing_token', 'no_possible_tokens', 'malformed_token', 'invalid_token', 'unknown_token', 'signature_mismatch' ), true ) ) {
635 return new WP_Error(
636 'site_not_connected',
637 __( 'This site is not connected to WordPress.com.', 'jetpack-stats-pkg' ),
638 array( 'status' => 400 )
639 );
640 }
641
642 if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response_body ) ) {
643 return is_wp_error( $response ) ? $response : new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );
644 }
645
646 return $data;
647 }
648
649 /**
650 * Fetch the stats when executed in WPCOM Simple.
651 *
652 * @param string $end_date The end date.
653 * @param int $number_of_days The number of days.
654 * @param string $escaped_post_ids The escaped post ids.
655 *
656 * @return array
657 */
658 protected function fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids ) {
659 return stats_get_daily_history( false, get_current_blog_id(), 'postviews', 'post_id', $end_date, $number_of_days, " AND post_id IN ($escaped_post_ids)", 0, true );
660 }
661
662 /**
663 * Convert stats array to object after sanity checking the array is valid.
664 *
665 * @since 0.11.0
666 *
667 * @param array $stats_array The stats array.
668 * @return WP_Error|object|null
669 */
670 public function convert_stats_array_to_object( $stats_array ) {
671
672 if ( is_wp_error( $stats_array ) ) {
673 return $stats_array;
674 }
675 $encoded_array = wp_json_encode( $stats_array, JSON_UNESCAPED_SLASHES );
676 if ( ! $encoded_array ) {
677 return new WP_Error( 'stats_encoding_error', 'Failed to encode stats array' );
678 }
679 return json_decode( $encoded_array );
680 }
681 }
682