PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 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 All 507 releases
← All changes | jetpack_vendor/automattic/jetpack-stats/src/class-wpcom-stats.php +274 -11 12.3.2 → 16.3-beta View file →
@@ -7,8 +7,9 @@
7 7
8 8 namespace Automattic\Jetpack\Stats;
9 9
10 10 use Automattic\Jetpack\Connection\Client;
11 +use Automattic\Jetpack\Status\Host;
11 12 use Jetpack_Options;
12 13 use WP_Error;
13 14
14 15 /**
@@ -49,8 +50,22 @@
49 50 */
50 51 protected $resource;
51 52
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 + /**
53 68 * Get site's stats.
54 69 *
55 70 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
56 71 * @param array $args Optional query parameters.
@@ -79,17 +94,36 @@
79 94 * Get site's top posts and pages by views.
80 95 *
81 96 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-posts/
82 97 * @param array $args Optional query parameters.
98 + * @param bool $override_cache Optional override cache.
83 99 * @return array|WP_Error
84 100 */
85 - public function get_top_posts( $args = array() ) {
101 + public function get_top_posts( $args = array(), $override_cache = false ) {
86 102 $this->resource = 'top-posts';
87 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 +
88 109 return $this->fetch_stats( $args );
89 110 }
90 111
91 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 + /**
92 126 * Get the details of a single video.
93 127 *
94 128 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video/%24post_id/
95 129 * @param int $post_id The video's ID.
@@ -196,15 +230,20 @@
196 230 /**
197 231 * Get a post's views.
198 232 *
199 233 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/post/%24post_id/
200 - * @param int $post_id The video's ID.
201 - * @param array $args Optional query parameters.
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.
202 237 * @return array|WP_Error
203 238 */
204 - public function get_post_views( $post_id, $args = array() ) {
239 + public function get_post_views( $post_id, $args = array(), $cache_in_meta = false ) {
205 240 $this->resource = sprintf( 'post/%d', $post_id );
206 241
242 + if ( $cache_in_meta ) {
243 + return $this->fetch_post_stats( $args, $post_id );
244 + }
245 +
207 246 return $this->fetch_stats( $args );
208 247 }
209 248
210 249 /**
@@ -221,8 +260,21 @@
221 260 return $this->fetch_stats( $args );
222 261 }
223 262
224 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 + /**
225 277 * Get site's followers.
226 278 *
227 279 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/followers/
228 280 * @param array $args Optional query parameters.
@@ -284,9 +336,34 @@
284 336 * @param array $args Optional query parameters.
285 337 * @return array|WP_Error
286 338 */
287 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 );
288 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 +
289 366 $this->resource = 'views/posts';
290 367
291 368 return $this->fetch_stats( $args );
292 369 }
@@ -367,11 +444,11 @@
367 444 */
368 445 protected function fetch_stats( $args = array() ) {
369 446 $endpoint = $this->build_endpoint();
370 447 $api_version = self::STATS_REST_API_VERSION;
371 - $cache_key = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args ) ) ) );
448 + $cache_key = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ) ) ) );
372 449 $transient_name = self::STATS_CACHE_TRANSIENT_PREFIX . $cache_key;
373 - $stats_cache = get_transient( $transient_name );
450 + $stats_cache = $this->should_bypass_cache() ? false : get_transient( $transient_name );
374 451
375 452 if ( $stats_cache ) {
376 453 $time = key( $stats_cache );
377 454 $data = $stats_cache[ $time ]; // WP_Error or string (JSON encoded object).
@@ -384,11 +461,35 @@
384 461 }
385 462
386 463 $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
387 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 +
388 476 // To reduce size in storage: store with time as key, store JSON encoded data.
389 - $cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats );
390 - $expiration = self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS;
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 + );
391 492 set_transient( $transient_name, array( time() => $cached_value ), $expiration );
392 493
393 494 return $wpcom_stats;
394 495 }
@@ -393,26 +494,188 @@
393 494 return $wpcom_stats;
394 495 }
395 496
396 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 + /**
397 616 * Fetches stats data from WPCOM.
398 617 *
399 618 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
400 619 * @param string $endpoint The stats endpoint.
401 620 * @param array $args The query arguments.
402 - * @return array|WP_Error.
621 + * @return array|WP_Error
403 622 */
404 623 protected function fetch_remote_stats( $endpoint, $args ) {
405 624 if ( is_array( $args ) && ! empty( $args ) ) {
406 625 $endpoint .= '?' . http_build_query( $args );
407 626 }
408 - $response = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION );
627 + $response = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION, array( 'timeout' => 20 ) );
409 628 $response_code = wp_remote_retrieve_response_code( $response );
410 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 );
411 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 +
412 642 if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response_body ) ) {
413 643 return is_wp_error( $response ) ? $response : new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );
414 644 }
415 645
416 - return json_decode( $response_body, true );
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 );
417 680 }
418 681 }