PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 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 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-stats/src/class-wpcom-stats.php +681 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,681 @@
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 +}