This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,424 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.19.7] - 2026-06-25
### Changed
- Defer loading the Stats REST API provider until a REST request is served, reducing the per-request PHP/opcache footprint. [#49806]
## [0.19.6] - 2026-06-15
### Changed
- Internal updates.
## [0.19.5] - 2026-06-10
### Changed
- Add `fetchpriority="low"` to the Stats script and remove `dns-prefetch` for `stats.wp.com` to improve LCP in Safari and Firefox. [#47936]
## [0.19.4] - 2026-06-08
### Fixed
- Abilities: Allow readonly stats abilities with optional input to run when no input is sent. [#49370]
## [0.19.3] - 2026-06-01
### Changed
- Internal updates.
## [0.19.2] - 2026-05-25
### Fixed
- Phan: Address `PhanPluginDuplicateConditionalNullCoalescing` violations. [#48887]
## [0.19.1] - 2026-05-19
### Changed
- Internal updates.
## [0.19.0] - 2026-05-04
### Added
- Abilities API: Register jetpack-stats abilities for site overview, top content, post views, visits, followers, and stats configuration. [#48286]
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [0.18.1] - 2026-02-26
### Added
- Add `AGENTS.md` for coding agent guidelines compliance. [#47330]
### Changed
- Exclude `AGENTS.md` and `CLAUDE.md` from production builds. [#47333]
## [0.18.0] - 2026-02-23
### Added
- Add `Transient_Cleanup` class to periodically clean up expired stats cache transients on sites without persistent object cache. [#47212]
### Changed
- Transient cleanup: Skip scheduling cron job entirely on sites with persistent object cache. [#47270]
## [0.17.8] - 2026-02-02
### Changed
- Update dependencies. [#39260]
## [0.17.7] - 2025-12-15
### Changed
- Improve Post Stats cache handling for invalid or error data. [#46211]
## [0.17.6] - 2025-12-08
### Fixed
- Ensure proper flags are used with `json_encode()`. [#46092]
## [0.17.5] - 2025-11-12
### Changed
- Internal updates.
## [0.17.4] - 2025-11-10
### Changed
- Tests: Ensure PHP 8.5 compatibility. [#45769]
## [0.17.3] - 2025-10-10
### Changed
- Handle lazy-loading of `WP_User` object properties. [#45450]
## [0.17.2] - 2025-07-21
### Changed
- Internal updates. [#39260]
## [0.17.1] - 2025-06-23
### Fixed
- Autoloader: Prevent double slash in autoloader path. [#44030]
## [0.17.0] - 2025-06-23
### Added
- Add archives endpoint support. [#44028]
## [0.16.3] - 2025-06-16
### Fixed
- Fix tracking of search terms and tags. [#43879]
- Fix parsing of taxonomies query. [#43879]
## [0.16.2] - 2025-04-28
### Changed
- Internal updates.
## [0.16.1] - 2025-04-15
### Fixed
- Fix PHP TypeError. [#43072]
## [0.16.0] - 2025-04-14
### Added
- Support tracking of archived pages and searches in tracking pixel. [#42368]
### Changed
- Update home tracking pixel to match archive ones. [#43043]
## [0.15.9] - 2025-03-31
### Changed
- Internal updates.
## [0.15.8] - 2025-03-24
### Changed
- Internal updates.
## [0.15.7] - 2025-03-18
### Changed
- Internal updates.
## [0.15.6] - 2025-03-17
### Changed
- Internal updates.
## [0.15.5] - 2025-03-12
### Changed
- Internal updates.
## [0.15.4] - 2025-03-10
### Changed
- Internal updates.
## [0.15.3] - 2025-03-03
### Fixed
- Tracking Pixel: Ensure the tracking script can be deferred, so it is not blocking. [#42175]
## [0.15.2] - 2025-02-24
### Changed
- Update dependencies. [#39260]
## [0.15.1] - 2025-02-03
### Fixed
- Code: Remove extra params on function calls. [#41263]
## [0.15.0] - 2025-01-06
### Added
- Stats: Add API support for location stats. [#40852]
## [0.14.1] - 2024-11-25
### Changed
- Updated dependencies. [#40286]
## [0.14.0] - 2024-11-18
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [0.13.5] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [0.13.4] - 2024-10-29
### Changed
- Internal updates. [#39260]
## [0.13.3] - 2024-09-23
### Changed
- Internal updates.
## [0.13.2] - 2024-09-05
### Changed
- Update dependencies. [#39253]
## [0.13.1] - 2024-08-23
### Changed
- Updated package dependencies. [#39004]
## [0.13.0] - 2024-06-10
### Added
- Staging: deprecating staging mode and separating the logic into is_development_site and in_safe_mode [#37023]
## [0.12.5] - 2024-05-06
### Changed
- Internal updates.
## [0.12.4] - 2024-04-26
### Changed
- Internal updates.
## [0.12.3] - 2024-04-25
### Changed
- Update dependencies.
## [0.12.2] - 2024-04-22
### Changed
- Internal updates.
## [0.12.1] - 2024-04-08
### Changed
- Internal updates.
## [0.12.0] - 2024-04-01
### Added
- Add the 'stats/blog' REST endpoint. [#36571]
- Composer: added version constant for ststs package. [#36657]
## [0.11.2] - 2024-03-25
### Changed
- Internal updates.
## [0.11.1] - 2024-03-18
### Changed
- Internal updates.
## [0.11.0] - 2024-02-26
### Added
- Add new method to convert stats data for external consumption. [#35865]
## [0.10.1] - 2024-02-19
### Fixed
- Avoid Fatal errors when saved stats data is a WP_Error object [#35746]
## [0.10.0] - 2024-02-05
### Added
- Stats fetching mechanism: add filter allowing one to customize how long we cache results. [#35421]
### Changed
- Permit overriding cache when retrieving post views. [#34557]
- Remove pre-6.3 asset enqueuing method, and relying on WordPress Core method instead. [#34072]
### Removed
- Stop requiring the Jetpack Assets Composer package. [#34072]
## [0.9.0] - 2023-12-25
### Added
- Stats: added passing select UTM parameters to Tracking Pixel requests. [#34431]
## [0.8.0] - 2023-12-11
### Changed
- Permit overriding cache when retrieving top posts. [#34153]
## [0.7.2] - 2023-12-03
### Changed
- Internal updates.
## [0.7.1] - 2023-11-21
## [0.7.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [0.6.6] - 2023-10-23
### Fixed
- Stats: Increase timeout to 20s. [#33549]
## [0.6.5] - 2023-08-28
### Changed
- Updated package dependencies. [#32605]
## [0.6.4] - 2023-08-09
### Added
- Stats: compatibility for AMP for WP plugin [#32328]
## [0.6.3] - 2023-05-15
### Changed
- PHP 8.1 compatibility updates [#30517]
## [0.6.2] - 2023-04-10
### Added
- Add Jetpack Autoloader package suggestion. [#29988]
## [0.6.1] - 2023-04-05
### Bug fixes
- Assets: avoid fatal error with outdated package versions. [#29947]
## [0.6.0] - 2023-04-04
### Changed
- Use core WordPress methods to add the Tracking pixel to the page, instead of printing the tracking pixel directly. [#29780]
### Deprecated
- Methods used to retrieve tracking pixels are now deprecated. No replacement needed, we now hook / enqueue the tracking pixels using core WP methods. [#29780]
## [0.5.2] - 2023-03-20
### Changed
- Updated package dependencies. [#29480]
## [0.5.1] - 2023-02-20
### Changed
- Minor internal updates.
## [0.5.0] - 2023-02-15
### Added
- Added new options to store the timestamp when Odyssey is enabled and disabled [#28794]
- Stats: Adds support for Notice control [#28857]
## [0.4.2] - 2023-02-08
### Changed
- Minor internal updates.
## [0.4.1] - 2023-01-11
### Changed
- Updated package dependencies.
## [0.4.0] - 2022-12-06
### Added
- Stats: added streak, highlights, insights for WPCOM_Stats [#27604]
### Changed
- Updated package dependencies. [#27688]
## [0.3.3] - 2022-11-28
### Changed
- Updated package dependencies. [#27043]
## [0.3.2] - 2022-11-07
### Changed
- Updated package dependencies. [#27278]
## [0.3.1] - 2022-10-25
### Changed
- Updated package dependencies. [#26705]
## [0.3.0] - 2022-10-19
### Changed
- Cache errors when fetching stats and reverted cache prefix. [#26922]
## [0.2.0] - 2022-10-13
### Changed
- Changed cache prefix [#26719]
- Updated readme for Stats package [#26759]
## 0.1.0 - 2022-10-11
### Added
- Stats: Add package scaffold [#26312]
- Stats package: Add 'jetpack.getBlog' XMLRPC endpoint [#26473]
- Stats package: Add entrypoint class for setting hooks and configuration. [#26601]
- Stats package: Introduce WPCOM_Stats class [#26530]
- Stats package: Manage Stats options [#26431]
- Stats Package: Tracking Pixel functionality [#26516]
### Changed
- Add mirror repo [#26750]
### Fixed
- Fixing static method which was called without self reference. [#26640]
[0.19.7]: https://github.com/Automattic/jetpack-stats/compare/v0.19.6...v0.19.7
[0.19.6]: https://github.com/Automattic/jetpack-stats/compare/v0.19.5...v0.19.6
[0.19.5]: https://github.com/Automattic/jetpack-stats/compare/v0.19.4...v0.19.5
[0.19.4]: https://github.com/Automattic/jetpack-stats/compare/v0.19.3...v0.19.4
[0.19.3]: https://github.com/Automattic/jetpack-stats/compare/v0.19.2...v0.19.3
[0.19.2]: https://github.com/Automattic/jetpack-stats/compare/v0.19.1...v0.19.2
[0.19.1]: https://github.com/Automattic/jetpack-stats/compare/v0.19.0...v0.19.1
[0.19.0]: https://github.com/Automattic/jetpack-stats/compare/v0.18.1...v0.19.0
[0.18.1]: https://github.com/Automattic/jetpack-stats/compare/v0.18.0...v0.18.1
[0.18.0]: https://github.com/Automattic/jetpack-stats/compare/v0.17.8...v0.18.0
[0.17.8]: https://github.com/Automattic/jetpack-stats/compare/v0.17.7...v0.17.8
[0.17.7]: https://github.com/Automattic/jetpack-stats/compare/v0.17.6...v0.17.7
[0.17.6]: https://github.com/Automattic/jetpack-stats/compare/v0.17.5...v0.17.6
[0.17.5]: https://github.com/Automattic/jetpack-stats/compare/v0.17.4...v0.17.5
[0.17.4]: https://github.com/Automattic/jetpack-stats/compare/v0.17.3...v0.17.4
[0.17.3]: https://github.com/Automattic/jetpack-stats/compare/v0.17.2...v0.17.3
[0.17.2]: https://github.com/Automattic/jetpack-stats/compare/v0.17.1...v0.17.2
[0.17.1]: https://github.com/Automattic/jetpack-stats/compare/v0.17.0...v0.17.1
[0.17.0]: https://github.com/Automattic/jetpack-stats/compare/v0.16.3...v0.17.0
[0.16.3]: https://github.com/Automattic/jetpack-stats/compare/v0.16.2...v0.16.3
[0.16.2]: https://github.com/Automattic/jetpack-stats/compare/v0.16.1...v0.16.2
[0.16.1]: https://github.com/Automattic/jetpack-stats/compare/v0.16.0...v0.16.1
[0.16.0]: https://github.com/Automattic/jetpack-stats/compare/v0.15.9...v0.16.0
[0.15.9]: https://github.com/Automattic/jetpack-stats/compare/v0.15.8...v0.15.9
[0.15.8]: https://github.com/Automattic/jetpack-stats/compare/v0.15.7...v0.15.8
[0.15.7]: https://github.com/Automattic/jetpack-stats/compare/v0.15.6...v0.15.7
[0.15.6]: https://github.com/Automattic/jetpack-stats/compare/v0.15.5...v0.15.6
[0.15.5]: https://github.com/Automattic/jetpack-stats/compare/v0.15.4...v0.15.5
[0.15.4]: https://github.com/Automattic/jetpack-stats/compare/v0.15.3...v0.15.4
[0.15.3]: https://github.com/Automattic/jetpack-stats/compare/v0.15.2...v0.15.3
[0.15.2]: https://github.com/Automattic/jetpack-stats/compare/v0.15.1...v0.15.2
[0.15.1]: https://github.com/Automattic/jetpack-stats/compare/v0.15.0...v0.15.1
[0.15.0]: https://github.com/Automattic/jetpack-stats/compare/v0.14.1...v0.15.0
[0.14.1]: https://github.com/Automattic/jetpack-stats/compare/v0.14.0...v0.14.1
[0.14.0]: https://github.com/Automattic/jetpack-stats/compare/v0.13.5...v0.14.0
[0.13.5]: https://github.com/Automattic/jetpack-stats/compare/v0.13.4...v0.13.5
[0.13.4]: https://github.com/Automattic/jetpack-stats/compare/v0.13.3...v0.13.4
[0.13.3]: https://github.com/Automattic/jetpack-stats/compare/v0.13.2...v0.13.3
[0.13.2]: https://github.com/Automattic/jetpack-stats/compare/v0.13.1...v0.13.2
[0.13.1]: https://github.com/Automattic/jetpack-stats/compare/v0.13.0...v0.13.1
[0.13.0]: https://github.com/Automattic/jetpack-stats/compare/v0.12.5...v0.13.0
[0.12.5]: https://github.com/Automattic/jetpack-stats/compare/v0.12.4...v0.12.5
[0.12.4]: https://github.com/Automattic/jetpack-stats/compare/v0.12.3...v0.12.4
[0.12.3]: https://github.com/Automattic/jetpack-stats/compare/v0.12.2...v0.12.3
[0.12.2]: https://github.com/Automattic/jetpack-stats/compare/v0.12.1...v0.12.2
[0.12.1]: https://github.com/Automattic/jetpack-stats/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/Automattic/jetpack-stats/compare/v0.11.2...v0.12.0
[0.11.2]: https://github.com/Automattic/jetpack-stats/compare/v0.11.1...v0.11.2
[0.11.1]: https://github.com/Automattic/jetpack-stats/compare/v0.11.0...v0.11.1
[0.11.0]: https://github.com/Automattic/jetpack-stats/compare/v0.10.1...v0.11.0
[0.10.1]: https://github.com/Automattic/jetpack-stats/compare/v0.10.0...v0.10.1
[0.10.0]: https://github.com/Automattic/jetpack-stats/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/Automattic/jetpack-stats/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/Automattic/jetpack-stats/compare/v0.7.2...v0.8.0
[0.7.2]: https://github.com/Automattic/jetpack-stats/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/Automattic/jetpack-stats/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/Automattic/jetpack-stats/compare/v0.6.6...v0.7.0
[0.6.6]: https://github.com/Automattic/jetpack-stats/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/Automattic/jetpack-stats/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/Automattic/jetpack-stats/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/Automattic/jetpack-stats/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/Automattic/jetpack-stats/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/Automattic/jetpack-stats/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/Automattic/jetpack-stats/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/Automattic/jetpack-stats/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/Automattic/jetpack-stats/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/Automattic/jetpack-stats/compare/v0.4.2...v0.5.0
[0.4.2]: https://github.com/Automattic/jetpack-stats/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Automattic/jetpack-stats/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Automattic/jetpack-stats/compare/v0.3.3...v0.4.0
[0.3.3]: https://github.com/Automattic/jetpack-stats/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Automattic/jetpack-stats/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Automattic/jetpack-stats/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Automattic/jetpack-stats/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/Automattic/jetpack-stats/compare/v0.1.0...v0.2.0
@@ -0,0 +1,266 @@
<?php
/**
* Stats Main
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Modules;
use Automattic\Jetpack\Stats\Abilities\Stats_Abilities;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Visitor;
use WP_User;
/**
* Stats Main class.
*
* Entrypoint for Stats.
*
* @since 0.1.0
*/
class Main {
/**
* Stats version.
* Mostly needed for backwards compatibility.
*/
const STATS_VERSION = '9';
/**
* Singleton Main instance.
*
* @var Main
**/
private static $instance = null;
/**
* Initializer.
* Used to configure the stats package, eg when called via the Config package.
*
* @return object
*/
public static function init() {
if ( null === self::$instance ) {
self::$instance = new Main();
}
return self::$instance;
}
/**
* Class constructor.
*
* @return void
*/
private function __construct() {
/**
* This avoids conflicts when running Stats package with older versions of the Jetpack plugin.
*
* On JP version 11.5-a.2 the hooks below were removed from the Jetpack plugin and it is safe
* to register them in the Stats package.
*/
$jp_plugin_version = Constants::get_constant( 'JETPACK__VERSION' );
if ( $jp_plugin_version && version_compare( $jp_plugin_version, '11.5-a.2', '<' ) ) {
return;
}
// Generate the tracking code after wp() has queried for posts.
add_action( 'template_redirect', array( __CLASS__, 'template_redirect' ), 1 );
add_action( 'wp_head', array( __CLASS__, 'hide_smile_css' ) );
add_action( 'embed_head', array( __CLASS__, 'hide_smile_css' ) );
// Map stats caps.
add_filter( 'map_meta_cap', array( __CLASS__, 'map_meta_caps' ), 10, 3 );
XMLRPC_Provider::init();
/*
* REST_Provider only registers its routes on REST init, so defer
* constructing it (and autoloading the class) until a REST request is
* served. A closure is used because rest_api_init passes the REST server
* to callbacks, which would otherwise be read as REST_Provider::init()'s
* $new_instance argument.
*/
add_action(
'rest_api_init',
static function () {
REST_Provider::init();
},
0
);
Transient_Cleanup::init();
// Clean up transient cron on module deactivation.
add_action( 'jetpack_deactivate_module_stats', array( Transient_Cleanup::class, 'unschedule_cleanup' ) );
// Set up package version hook.
add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
// Register WP Abilities API surface. Gated behind the
// `jetpack_wp_abilities_enabled` filter inside Registrar::init(),
// which defaults to false — so this call is safe to make unconditionally
// and still opt-in per-site until the flag is flipped.
Stats_Abilities::init();
}
/**
* Checks if filter is set and dnt is enabled.
*
* @return bool
*/
public static function jetpack_is_dnt_enabled() {
/**
* Filter the option which decides honor DNT or not.
*
* @module stats
* @since-jetpack 6.1.0
*
* @param bool false Honors DNT for clients who don't want to be tracked. Defaults to false. Set to true to enable.
*/
if ( false === apply_filters( 'jetpack_honor_dnt_header_for_stats', false ) ) {
return false;
}
foreach ( $_SERVER as $name => $value ) {
if ( 'http_dnt' === strtolower( $name ) && 1 === (int) $value ) {
return true;
}
}
return false;
}
/**
* Maps view_stats cap to read cap as needed.
*
* @access public
* @param mixed $caps Caps.
* @param mixed $cap Cap.
* @param mixed $user_id User ID.
* @return array Possibly mapped capabilities for meta capability.
*/
public static function map_meta_caps( $caps, $cap, $user_id ) {
// Map view_stats to exists.
if ( 'view_stats' === $cap ) {
$user = new WP_User( $user_id );
// WordPress 6.9 introduced lazy-loading of some WP_User properties, including `roles`.
// It also made said properties protected, so we can't modify keys directly.
$user_roles = $user->roles;
$user_role = array_shift( $user_roles ); // Work with the copy
$stats_roles = Options::get_option( 'roles' );
// Is the users role in the available stats roles?
if ( is_array( $stats_roles ) && in_array( $user_role, $stats_roles, true ) ) {
$caps = array( 'read' );
}
}
return $caps;
}
/**
* Stats Template Redirect.
*
* @access public
* @return void
*/
public static function template_redirect() {
if ( ! self::should_track() ) {
return;
}
add_action( 'wp_enqueue_scripts', array( Tracking_Pixel::class, 'enqueue_stats_script' ), 101 );
add_action( 'wp_footer', array( Tracking_Pixel::class, 'add_amp_pixel' ), 101 );
add_action( 'web_stories_print_analytics', array( Tracking_Pixel::class, 'add_amp_pixel' ), 101 );
}
/**
* CSS to hide the tracking pixel smiley.
* It is now hidden for everyone (used to be visible if you had set the hide_smile option).
*
* @access public
* @return void
*/
public static function hide_smile_css() {
if ( ! self::should_track() ) {
return;
}
?>
<style>img#wpstats{display:none}</style>
<?php
}
/**
* Whether we should add the tracking pixel.
*
* @return bool
*/
public static function should_track() {
global $current_user;
// Not connected sites should not generate tracking stats.
if ( ! ( new Connection_Manager() )->is_connected() ) {
return false;
}
// If the stats module is disabled we should not generate tracking stats.
if ( ! ( new Modules() )->is_active( 'stats' ) ) {
return false;
}
// Do not generate tracking stats for feeds, robots, embeds, previews
// or to honour the DNT headers.
if (
is_feed()
|| is_robots()
|| is_embed()
|| is_trackback()
|| is_preview()
|| self::jetpack_is_dnt_enabled()
) {
return false;
}
// Sites in Safe Mode should not generate tracking stats.
$status = new Status();
if ( $status->in_safe_mode() ) {
return false;
}
// Should we be counting this user's views?
if ( ! empty( $current_user->ID ) ) {
$count_roles = Options::get_option( 'count_roles' );
if ( ! is_array( $count_roles ) || ! array_intersect( $current_user->roles, $count_roles ) ) {
return false;
}
}
/**
* Allow excluding specific IP addresses from being tracked in Stats.
* Note: for this to work well, visitors' IP addresses must:
* - be stored and returned properly in IP address headers;
* - not be impacted by any caching setup on your site.
*
* @module stats
*
* @since-jetpack 10.6
*
* @param array $excluded_ips An array of IP address strings to exclude from tracking.
*/
$excluded_ips = (array) apply_filters( 'jetpack_stats_excluded_ips', array() );
// Should we be counting views for this IP address?
$current_user_ip = ( new Visitor() )->get_ip( true );
if (
! empty( $excluded_ips )
&& in_array( $current_user_ip, $excluded_ips, true )
) {
return false;
}
return true;
}
}
@@ -0,0 +1,175 @@
<?php
/**
* Stats Options
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
use Jetpack_Options;
/**
* Stats Options class.
*/
class Options {
/**
* Option name.
*
* @var string $option_name The 'stats' option name
*/
const OPTION_NAME = 'stats_options';
/**
* Stats Options.
*
* @var array $options An array of associated Stats options (default empty)
*/
protected static $options = array();
/**
* Stats Get Options.
*
* @return array
*/
public static function get_options() {
// Make sure we only get options from the database once per connection.
if ( array() !== self::$options ) {
return self::$options;
}
self::$options = get_option( self::OPTION_NAME, array() );
self::$options = array_merge( self::get_defaults(), self::$options );
if ( self::$options['version'] < Main::STATS_VERSION ) {
self::upgrade_options( self::$options );
}
return self::$options;
}
/**
* Get Stats Option.
*
* @param string $option Option name.
* @return mixed|null
*/
public static function get_option( $option ) {
if ( 'blog_id' === $option ) {
return Jetpack_Options::get_option( 'id' );
}
$options = self::get_options();
if ( isset( $options[ $option ] ) ) {
return $options[ $option ];
}
return null;
}
/**
* Stats Set Option.
*
* @param string $option The option name.
* @param mixed $value The option Value.
* @return bool
*/
public static function set_option( $option, $value ) {
$options = self::get_options();
$options[ $option ] = $value;
return self::set_options( $options );
}
/**
* Stats Set Options.
*
* @access public
* @param array $options Options.
* @return bool
*/
public static function set_options( $options ) {
if ( ! is_array( $options ) ) {
return false;
}
$defaults = self::get_defaults();
$stored_options = get_option( self::OPTION_NAME, array() );
$all_options = array_merge( $defaults, $stored_options );
$options = array_merge( $all_options, $options );
$allowed_options = array_keys( $defaults );
foreach ( $options as $option_name => $option_value ) {
if ( ! in_array( $option_name, $allowed_options, true ) ) {
unset( $options[ $option_name ] );
}
}
$options['blog_id'] = Jetpack_Options::get_option( 'id' );
$options['version'] = Main::STATS_VERSION;
$success = update_option( self::OPTION_NAME, $options );
if ( true === $success ) {
self::$options = $options;
}
return $success;
}
/**
* Stats Upgrade Options.
*
* Ideally this should be a protected method but keeping it public
* to maintain backwards compatibility with stats_upgrade_options.
*
* @access public
* @param array $options The stats options.
* @return array|bool
*/
public static function upgrade_options( $options ) {
if ( isset( $options['reg_users'] ) ) {
if ( ! function_exists( 'get_editable_roles' ) ) {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
if ( $options['reg_users'] ) {
$options['count_roles'] = array_keys( get_editable_roles() );
}
unset( $options['reg_users'] );
}
if ( false === self::set_options( $options ) ) {
return false;
}
return self::$options;
}
/**
* Default Stats related options.
*
* Exposed so consumers (e.g. the Abilities API surface) can introspect
* each option's default value and infer its type without redeclaring
* a parallel allow-list of bool/array fields.
*
* @return array
*/
public static function get_defaults() {
return array(
'admin_bar' => true,
'roles' => array( 'administrator' ),
'count_roles' => array(),
'do_not_track' => true, // @todo
'blog_id' => Jetpack_Options::get_option( 'id' ),
'version' => Main::STATS_VERSION,
'collapse_nudges' => false,
'enable_odyssey_stats' => true,
'odyssey_stats_changed_at' => 0,
'notices' => array(),
'views' => 0,
);
}
}
@@ -0,0 +1,30 @@
<?php
/**
* The Package_Version class.
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
/**
* The Package_Version class.
*/
class Package_Version {
const PACKAGE_VERSION = '0.19.7';
const PACKAGE_SLUG = 'stats';
/**
* Adds the package slug and version to the package version tracker's data.
*
* @param array $package_versions The package version array.
*
* @return array The package version array.
*/
public static function send_package_version_to_tracker( $package_versions ) {
$package_versions[ self::PACKAGE_SLUG ] = self::PACKAGE_VERSION;
return $package_versions;
}
}
@@ -0,0 +1,90 @@
<?php
/**
* The Stats REST Provider class.
*
* @package @automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
use Automattic\Jetpack\Connection\Rest_Authentication;
use Automattic\Jetpack\Connection\REST_Connector;
use WP_Error;
use WP_REST_Server;
/**
* The REST API provider class.
*
* @since 0.12.0
*/
class REST_Provider {
/**
* Singleton instance.
*
* @var REST_Provider
**/
private static $instance = null;
/**
* Private constructor.
*
* Use the static::init() method to get an instance.
*/
private function __construct() {
add_action( 'rest_api_init', array( $this, 'initialize_rest_api' ) );
}
/**
* Initialize class and get back a singleton instance.
*
* @param bool $new_instance Force create new instance.
*
* @return static
*/
public static function init( $new_instance = false ) {
if ( null === self::$instance || $new_instance ) {
self::$instance = new static();
}
return self::$instance;
}
/**
* Initialize the REST API.
*
* @return void
*/
public function initialize_rest_api() {
register_rest_route(
'jetpack/v4',
'/stats/blog',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_blog' ),
'permission_callback' => array( $this, 'get_blog_permission_check' ),
)
);
}
/**
* Get the stats blog data.
*
* @since 0.12.0
*
* @return array
*/
public function get_blog() {
return XMLRPC_Provider::init()->get_blog();
}
/**
* Check permissions for the `/stats/blog` endpoint.
*
* @return WP_Error|true
*/
public function get_blog_permission_check() {
return Rest_Authentication::is_signed_with_blog_token()
? true
: new WP_Error( 'invalid_permission_stats_get_blog', REST_Connector::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
}
@@ -0,0 +1,438 @@
<?php
/**
* Stats Tracking_Pixel
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
use Jetpack_Options;
use WP_Post;
/**
* Stats Tracking_Pixel class.
*
* Responsible for embedding the Stats tracking pixel.
*
* @since 0.1.0
*/
class Tracking_Pixel {
/**
* Array name.
*
* @var string $array_name The 'stats' array name
*/
const STATS_ARRAY_TO_STRING_FILTER = 'stats_array';
const TRACKED_UTM_PARAMETERS = array(
'utm_id',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'utm_source_platform',
'utm_creative_format',
'utm_marketing_tactic',
);
/**
* Stats Build View Data.
*
* @access public
* @return array
*/
public static function build_view_data() {
global $wp_the_query;
$blog = Jetpack_Options::get_option( 'id' );
$tz = get_option( 'gmt_offset' );
$v = 'ext';
$blog_url = wp_parse_url( site_url() );
$srv = $blog_url['host'];
$is_not_post = false;
if ( $wp_the_query->is_single || $wp_the_query->is_page || $wp_the_query->is_posts_page ) {
// Store and reset the queried_object and queried_object_id
// Otherwise, redirect_canonical() will redirect to home_url( '/' ) for show_on_front = page sites where home_url() is not all lowercase.
// Repro:
// 1. Set home_url = https://ExamPle.com/
// 2. Set show_on_front = page
// 3. Set page_on_front = something
// 4. Visit https://example.com/ !
$queried_object = $wp_the_query->queried_object ?? null;
$queried_object_id = $wp_the_query->queried_object_id ?? null;
try {
$post_obj = $wp_the_query->get_queried_object();
$post = $post_obj instanceof WP_Post ? $post_obj->ID : '0';
} finally {
$wp_the_query->queried_object = $queried_object;
$wp_the_query->queried_object_id = $queried_object_id;
}
} else {
$post = '0';
$is_not_post = true;
}
$view_data = compact( 'v', 'blog', 'post', 'tz', 'srv' );
// Batcache removes some of the UTM params from $_GET, we need to extract them from uri directly instead.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're sanitizing individual params in the loop.
$url_query = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_QUERY );
parse_str( (string) $url_query, $url_params );
foreach ( self::TRACKED_UTM_PARAMETERS as $utm_parameter ) {
if ( isset( $url_params[ $utm_parameter ] ) && is_scalar( $url_params[ $utm_parameter ] ) ) {
$view_data[ $utm_parameter ] = substr( sanitize_textarea_field( wp_unslash( $url_params[ $utm_parameter ] ) ), 0, 255 );
}
}
if ( $is_not_post ) {
if ( $wp_the_query->is_home() ) {
$view_data['arch_home'] = '1';
} elseif ( $wp_the_query->is_search() ) {
$search_term = $wp_the_query->query['s'] ?? $wp_the_query->query_vars['s'] ?? '';
$view_data['arch_search'] = sanitize_text_field( $search_term );
$view_data['arch_filters'] = sanitize_text_field( self::build_search_filters( $wp_the_query ) );
$view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
} elseif ( $wp_the_query->is_archive() ) {
if ( $wp_the_query->is_date ) {
$query = $wp_the_query->query;
$date_parts = array_filter( array( $query['year'] ?? null, $query['monthnum'] ?? null, $query['day'] ?? null ) );
$date = implode( '/', $date_parts );
$view_data['arch_date'] = $date;
}
if ( $wp_the_query->is_category ) {
$view_data['arch_cat'] = $wp_the_query->query['category_name'] ?? $wp_the_query->query_vars['category_name'] ?? '';
}
if ( $wp_the_query->is_tag ) {
$view_data['arch_tag'] = $wp_the_query->query['tag'] ?? $wp_the_query->query_vars['tag'] ?? '';
}
if ( $wp_the_query->is_author ) {
$view_data['arch_author'] = $wp_the_query->query['author_name'] ?? '';
}
if ( $wp_the_query->is_tax ) {
$query = $wp_the_query->query;
if ( is_array( $query ) && count( $query ) === 1 ) {
$view_data[ 'arch_tax_' . array_keys( $query )[0] ] = array_values( $query )[0];
}
}
$view_data['arch_results'] = $wp_the_query->posts ? $wp_the_query->post_count : 0;
} elseif ( $wp_the_query->is_404() ) {
$view_data['arch_err'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
} else {
$view_data['arch_other'] = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
}
}
return $view_data;
}
/**
* Collect the tracking data for a search page.
*
* @access private
* @param \WP_Query $query The WP_Query object to parse all the filters from.
* @return string The search filters in a URL query string format.
*/
private static function build_search_filters( $query ) {
$data = array(
'posts_per_page' => $query->get( 'posts_per_page' ),
'paged' => ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1,
'orderby' => $query->get( 'orderby' ),
'order' => $query->get( 'order' ),
);
if ( $query->get( 'author_name' ) ) {
$data['author_name'] = $query->get( 'author_name' );
}
$filters = http_build_query( $data );
$the_tax_query = $query->tax_query;
$terms = array();
if ( ! empty( $the_tax_query->queried_terms ) && is_array( $the_tax_query->queried_terms ) ) {
foreach ( $the_tax_query->queries as $tax_query ) {
if ( ! is_array( $tax_query ) || ! isset( $tax_query['taxonomy'] ) ) {
continue;
}
$taxonomy = $tax_query['taxonomy'];
if ( ! isset( $terms[ $taxonomy ] ) || ! is_array( $terms[ $taxonomy ] ) ) {
$terms[ $taxonomy ] = array();
}
$terms[ $taxonomy ] = array_merge( $terms[ $taxonomy ], $tax_query['terms'] );
}
}
if ( ! empty( $terms ) ) {
$filters .= '&terms=' . wp_json_encode( $terms, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
}
return $filters;
}
/**
* Build the Stats tracking details.
*
* @since 0.6.0
*
* @access private
* @param array $data Array of data for the AMP pixel tracker.
* @return string
*/
private static function build_stats_details( $data ) {
$data_stats_array = self::stats_array_to_string( $data );
return sprintf(
'_stq = window._stq || [];
_stq.push([ "view", %1$s ]);
_stq.push([ "clickTrackerInit", "%2$s", "%3$s" ]);',
$data_stats_array,
$data['blog'],
$data['post']
);
}
/**
* Add fetchpriority="low" to the Stats script attributes.
*
* Reduces network contention with resources in the critical rendering path (e.g., the LCP
* element image). This benefits Safari and Firefox, which don't automatically assign low
* priority to async/defer scripts (unlike Chrome).
*
* @since 0.19.5
*
* @param array $attributes Script tag attributes.
* @return array Modified attributes.
*/
public static function add_low_fetchpriority( $attributes ) {
// WordPress derives the tag id from the enqueue handle as "{handle}-js", so the
// 'jetpack-stats' script (registered in enqueue_stats_script()) prints as
// 'jetpack-stats-js'. Keep this in sync if the handle is ever renamed.
if ( isset( $attributes['id'] ) && 'jetpack-stats-js' === $attributes['id'] ) {
$attributes['fetchpriority'] = 'low';
}
return $attributes;
}
/**
* Remove the dns-prefetch resource hint for stats.wp.com.
*
* WordPress automatically adds dns-prefetch hints for enqueued script hosts via
* wp_dependencies_unique_hosts(). Since we're deprioritizing the stats script,
* the dns-prefetch is counterproductive — it front-loads DNS resolution for a
* resource we're intentionally delaying.
*
* @since 0.19.5
*
* @param array $urls Array of resource hint URLs.
* @param string $relation_type The relation type (dns-prefetch, preconnect, etc.).
* @return array Filtered URLs.
*/
public static function remove_stats_dns_prefetch( $urls, $relation_type ) {
if ( 'dns-prefetch' !== $relation_type ) {
return $urls;
}
return array_filter(
$urls,
static function ( $url ) {
// Resource hints can be arrays that carry the URL under an 'href' key.
if ( is_array( $url ) ) {
$candidate = ( isset( $url['href'] ) && is_string( $url['href'] ) ) ? $url['href'] : '';
} elseif ( is_string( $url ) ) {
$candidate = $url;
} else {
return true; // Unknown entry shape; leave it untouched.
}
// dns-prefetch entries arrive in several shapes: WordPress core emits bare
// hosts ('stats.wp.com') via wp_dependencies_unique_hosts(), while other
// filters may add scheme-relative ('//stats.wp.com') or full URLs. Normalize
// each to a host so we drop stats.wp.com exactly without removing look-alike
// hosts such as 'mystats.wp.com' or 'stats.wp.com.evil.tld'.
if ( str_starts_with( $candidate, '//' ) ) {
$host = wp_parse_url( 'https:' . $candidate, PHP_URL_HOST );
} elseif ( str_contains( $candidate, '://' ) ) {
$host = wp_parse_url( $candidate, PHP_URL_HOST );
} else {
$host = $candidate; // Bare host form, e.g. 'stats.wp.com'.
}
return ! is_string( $host ) || 'stats.wp.com' !== strtolower( $host );
}
);
}
/**
* Enqueue the Stats pixel.
* Do not use this function directly, it is hooked into `wp_enqueue_scripts`.
*
* @access public
* @return void
*/
public static function enqueue_stats_script() {
if ( self::is_amp_request() ) {
return;
}
wp_enqueue_script(
'jetpack-stats',
'https://stats.wp.com/e-' . gmdate( 'YW' ) . '.js',
array(),
null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
array(
'in_footer' => true,
'strategy' => 'defer',
)
);
add_filter( 'wp_script_attributes', array( static::class, 'add_low_fetchpriority' ) );
add_filter( 'wp_resource_hints', array( static::class, 'remove_stats_dns_prefetch' ), 100, 2 );
$data = self::build_view_data();
/**
* Filter the parameters added to the JavaScript stats tracking code.
*
* @module stats
*
* @since-jetpack 10.9
*
* @param array $data Array of options about the site and page you're on.
*/
$data = (array) apply_filters( 'jetpack_stats_footer_js_data', $data );
$triggers = self::build_stats_details( $data );
wp_add_inline_script(
'jetpack-stats',
$triggers,
'before'
);
}
/**
* Gets the stats footer for AMP output.
*
* @access private
* @param array $data Array of data for the AMP pixel tracker.
* @return string Returns the footer to add for the Stats tracker in an AMP scenario.
*/
private static function get_amp_footer( $data ) {
/**
* Filter the parameters added to the AMP pixel tracking code.
*
* @module stats
*
* @since-jetpack 10.9
*
* @param array $data Array of options about the site and page you're on.
*/
$data = (array) apply_filters( 'jetpack_stats_footer_amp_data', $data );
$data['host'] = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // input var ok.
$data['rand'] = 'RANDOM'; // AMP placeholder.
$data['ref'] = 'DOCUMENT_REFERRER'; // AMP placeholder.
$data = array_map( 'rawurlencode', $data );
$pixel_url = add_query_arg( $data, 'https://pixel.wp.com/g.gif' );
return '<amp-pixel src="' . esc_url( $pixel_url ) . '"></amp-pixel>';
}
/**
* Build an AMP pixel.
* Do not use this function directly, it is hooked into `wp_footer`.
*
* @access public
* @return void
*/
public static function add_amp_pixel() {
$data = self::build_view_data();
if ( ! self::is_amp_request() ) {
return;
}
$pixel = self::get_amp_footer( $data );
echo $pixel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Stats Footer.
*
* @deprecated 0.6.0
*
* @access public
* @return void
*/
public static function add_to_footer() {
_deprecated_function( __METHOD__, '0.6.0' );
}
/**
* Gets the footer to add for the Stats tracker.
*
* @deprecated 0.6.0
*
* @access public
* @param array $data Array of data for the JS stats tracker.
* @return void
*/
public static function get_footer_to_add( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_deprecated_function( __METHOD__, '0.6.0' );
}
/**
* Render the stats footer. Kept for backward compatibility on legacy AMF views.
*
* @deprecated 0.6.0
*
* @access public
* @param array $data Array of data for the JS stats tracker.
*/
public static function render_footer( $data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
_deprecated_function( __METHOD__, '0.6.0' );
}
/**
* Render the stats footer for AMP output. Kept for backward compatibility.
*
* @access public
* @param array $data Array of data for the AMP pixel tracker.
*/
public static function render_amp_footer( $data ) {
print self::get_amp_footer( $data ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Creates the "array" string used as part of the JS tracker.
*
* @access private
* @param array $kvs Array of options about the site and page you're on.
* @return string
*/
private static function stats_array_to_string( $kvs ) {
/**
* Filters the options added to the JavaScript Stats tracking code.
*
* @since-jetpack 1.1.0
*
* @param array $kvs Array of options about the site and page you're on.
*/
$kvs = (array) apply_filters( self::STATS_ARRAY_TO_STRING_FILTER, $kvs );
$kvs = array_map( 'strval', $kvs );
// Encode into JSON object for direct use in JS.
return wp_json_encode( $kvs, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
}
/**
* Does the page return AMP content.
*
* @return bool $is_amp_request Are we on AMP view.
*/
private static function is_amp_request() {
$is_amp_request = ( function_exists( 'amp_is_request' ) && amp_is_request() );
$is_amp_request = $is_amp_request || ( function_exists( 'ampforwp_is_amp_endpoint' ) && ampforwp_is_amp_endpoint() );
/**
* Returns true if the current request should return valid AMP content.
*
* @since 6.2.0
*
* @param boolean $is_amp_request Is this request supposed to return valid AMP content?
*/
return apply_filters( 'jetpack_is_amp_request', $is_amp_request );
}
}
@@ -0,0 +1,244 @@
<?php
/**
* Stats Transient Cleanup
*
* Handles cleanup of expired stats cache transients to prevent database bloat
* on sites without a persistent object cache.
*
* Adapted from Brute Force Protection transient cleanup.
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
/**
* Transient_Cleanup class.
*
* Provides a scheduled cron job to clean up expired stats transients.
* WordPress transient garbage collection is "lazy" - expired transients are only
* deleted when accessed via get_transient(). Stats transients use dynamic cache keys
* based on query parameters, so expired entries are rarely accessed again and
* accumulate indefinitely in wp_options on sites without an external object cache.
*
* @since 0.18.0
*/
class Transient_Cleanup {
/**
* Cron hook name.
*/
const CRON_HOOK = 'jetpack_stats_transient_cleanup';
/**
* Batch size for transient cleanup.
*
* Used as the SQL LIMIT per prefix and as the threshold for stopping iteration.
* Actual deletions per run may slightly exceed this when processing multiple prefixes.
*/
const BATCH_SIZE = 5000;
/**
* Cron interval in seconds (8 hours).
*/
const CRON_INTERVAL = 28800;
/**
* Initialize the transient cleanup.
*
* @return void
*/
public static function init() {
// Register the cron hook.
add_action( self::CRON_HOOK, array( static::class, 'run_cleanup' ) );
// Register custom cron schedule.
// phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- 8 hours is intentional for batched transient cleanup.
add_filter( 'cron_schedules', array( static::class, 'add_cron_schedule' ) );
// Schedule the cron job if not already scheduled.
add_action( 'admin_init', array( static::class, 'schedule_cleanup' ) );
}
/**
* Add custom cron schedule for 8-hour intervals.
*
* @param array $schedules Existing cron schedules.
* @return array Modified cron schedules.
*/
public static function add_cron_schedule( $schedules ) {
if ( ! isset( $schedules['jetpack_stats_eight_hours'] ) ) {
$schedules['jetpack_stats_eight_hours'] = array(
'interval' => self::CRON_INTERVAL,
'display' => __( 'Every Eight Hours', 'jetpack-stats' ),
);
}
return $schedules;
}
/**
* Schedule the cleanup cron job.
*
* Skips scheduling on sites with persistent object cache since transients
* auto-expire there and cleanup is unnecessary.
*
* @return void
*/
public static function schedule_cleanup() {
// Skip scheduling on sites with persistent object cache - not needed there.
if ( wp_using_ext_object_cache() ) {
// Unschedule if it was previously scheduled (e.g., object cache was added later).
if ( wp_next_scheduled( self::CRON_HOOK ) ) {
wp_clear_scheduled_hook( self::CRON_HOOK );
}
return;
}
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_event( time() + self::CRON_INTERVAL, 'jetpack_stats_eight_hours', self::CRON_HOOK );
}
}
/**
* Unschedule the cleanup cron job.
* Hooked to 'jetpack_deactivate_module_stats' in Main::__construct().
*
* @return void
*/
public static function unschedule_cleanup() {
wp_clear_scheduled_hook( self::CRON_HOOK );
}
/**
* Get the list of transient prefixes to clean up.
*
* @return array List of transient prefixes.
*/
public static function get_transient_prefixes() {
$prefixes = array(
WPCOM_Stats::STATS_CACHE_TRANSIENT_PREFIX, // jetpack_restapi_stats_cache_
);
/**
* Filter the list of transient prefixes to clean up.
*
* @since 0.18.0
*
* @param array $prefixes List of transient prefixes.
*/
$filtered = apply_filters( 'jetpack_stats_transient_cleanup_prefixes', $prefixes );
// Normalize filtered value: ensure array, filter to non-empty strings, dedupe.
if ( ! is_array( $filtered ) ) {
$filtered = $prefixes;
}
$filtered = array_filter(
$filtered,
function ( $prefix ) {
return is_string( $prefix ) && '' !== $prefix;
}
);
return array_unique( $filtered );
}
/**
* Run the transient cleanup.
*
* @return int|false Number of deleted transient options (two entries per transient), or false if skipped.
*/
public static function run_cleanup() {
/**
* Filter to disable transient cleanup.
*
* @since 0.18.0
*
* @param bool $disabled Whether to disable transient cleanup. Default false.
*/
if ( apply_filters( 'jetpack_stats_transient_cleanup_disabled', false ) ) {
return false;
}
// Skip if using external object cache - transients auto-expire there.
if ( wp_using_ext_object_cache() ) {
return false;
}
$total_deleted = 0;
$prefixes = self::get_transient_prefixes();
foreach ( $prefixes as $prefix ) {
$deleted = self::purge_expired_transients( $prefix );
$total_deleted += $deleted;
// Stop processing additional prefixes once we've reached the batch threshold.
// Note: total may exceed BATCH_SIZE since each prefix can delete up to BATCH_SIZE.
if ( $total_deleted >= self::BATCH_SIZE ) {
break;
}
}
return $total_deleted;
}
/**
* Purge expired transients for a specific prefix.
*
* @param string $prefix The transient prefix to clean up.
* @return int Number of deleted transient options, two entries for each transient.
*/
private static function purge_expired_transients( $prefix ) {
global $wpdb;
$now = time();
$timeout_prefix = '_transient_timeout_' . $prefix;
$like_pattern = $wpdb->esc_like( $timeout_prefix ) . '%';
// Find expired transients by querying timeout entries.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$transients = $wpdb->get_col(
$wpdb->prepare(
"SELECT SUBSTRING(option_name, %d) AS transient_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value < %d LIMIT %d",
strlen( $timeout_prefix ) + 1,
$like_pattern,
$now,
self::BATCH_SIZE
)
);
if ( empty( $transients ) ) {
return 0;
}
// Build list of option names to delete (both transient and timeout entries).
$option_names = array();
foreach ( $transients as $transient ) {
$option_names[] = '_transient_' . $prefix . $transient;
$option_names[] = '_transient_timeout_' . $prefix . $transient;
}
// Delete in chunks to avoid excessively long SQL queries.
// Each option name can be ~80 chars, so 50 items ≈ 4KB per query.
$chunks = array_chunk( $option_names, 50 );
$total_deleted = 0;
foreach ( $chunks as $chunk ) {
$placeholders = implode( ', ', array_fill( 0, count( $chunk ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a list of %s.
$chunk
)
);
if ( false !== $result ) {
$total_deleted += $result;
}
}
return $total_deleted;
}
}
@@ -0,0 +1,623 @@
<?php
/**
* Stats WPCOM_Stats
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Status\Host;
use Jetpack_Options;
use WP_Error;
/**
* Stats WPCOM_Stats class.
*
* Responsible for fetching Stats related data from WPCOM.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
*
* @since 0.1.0
*/
class WPCOM_Stats {
/**
* Transient prefix for storing Stats results from the REST API.
*
* @var string
*/
const STATS_CACHE_TRANSIENT_PREFIX = 'jetpack_restapi_stats_cache_';
/**
* Time, in minutes, to cache stats results from the REST API.
*
* @var int
*/
const STATS_CACHE_EXPIRATION_IN_MINUTES = 5;
/**
* Stats REST API version.
*
* @var string
*/
const STATS_REST_API_VERSION = '1.1';
/**
* The stats resource to fetch results for.
*
* @var string
*/
protected $resource;
/**
* If the site is on WPCOM Simple.
*
* @var bool
*/
protected $is_wpcom_simple;
/**
* The constructor.
*/
public function __construct() {
$this->is_wpcom_simple = ( new Host() )->is_wpcom_simple();
}
/**
* Get site's stats.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
* @param array $args Optional query parameters.
* @return array| WP_Error
*/
public function get_stats( $args = array() ) {
$this->resource = '';
return $this->fetch_stats( $args );
}
/**
* Get site's summarized views, visitors, likes and comments.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/summary/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_stats_summary( $args = array() ) {
$this->resource = 'summary';
return $this->fetch_stats( $args );
}
/**
* Get site's top posts and pages by views.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-posts/
* @param array $args Optional query parameters.
* @param bool $override_cache Optional override cache.
* @return array|WP_Error
*/
public function get_top_posts( $args = array(), $override_cache = false ) {
$this->resource = 'top-posts';
// Needed for the Top Posts block, so users can preview changes instantly.
if ( $override_cache ) {
return $this->fetch_remote_stats( $this->build_endpoint(), $args );
}
return $this->fetch_stats( $args );
}
/**
* Get site's archive pages by views.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/archives/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_archives( $args = array() ) {
$this->resource = 'archives';
return $this->fetch_stats( $args );
}
/**
* Get the details of a single video.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video/%24post_id/
* @param int $post_id The video's ID.
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_video_details( $post_id, $args = array() ) {
$this->resource = sprintf( 'video/%d', $post_id );
return $this->fetch_stats( $args );
}
/**
* Get site's referrers.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/referrers/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_referrers( $args = array() ) {
$this->resource = 'referrers';
return $this->fetch_stats( $args );
}
/**
* Get site's outbound clicks.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/clicks/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_clicks( $args = array() ) {
$this->resource = 'clicks';
return $this->fetch_stats( $args );
}
/**
* Get site's views by tags and categories.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/tags/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_tags( $args = array() ) {
$this->resource = 'tags';
return $this->fetch_stats( $args );
}
/**
* Get site's top authors.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-authors/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_top_authors( $args = array() ) {
$this->resource = 'top-authors';
return $this->fetch_stats( $args );
}
/**
* Get site's top comment authors and most-commented posts.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/comments/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_top_comments( $args = array() ) {
$this->resource = 'comments';
return $this->fetch_stats( $args );
}
/**
* Get site's video plays.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video-plays/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_video_plays( $args = array() ) {
$this->resource = 'video-plays';
return $this->fetch_stats( $args );
}
/**
* Get site's file downloads.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/file-downloads/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_file_downloads( $args = array() ) {
$this->resource = 'file-downloads';
return $this->fetch_stats( $args );
}
/**
* Get a post's views.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/post/%24post_id/
* @param int $post_id The post's ID.
* @param array $args Optional query parameters.
* @param bool $cache_in_meta Optional should cache in post meta.
* @return array|WP_Error
*/
public function get_post_views( $post_id, $args = array(), $cache_in_meta = false ) {
$this->resource = sprintf( 'post/%d', $post_id );
if ( $cache_in_meta ) {
return $this->fetch_post_stats( $args, $post_id );
}
return $this->fetch_stats( $args );
}
/**
* Get site's views by country.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/country-views/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_views_by_country( $args = array() ) {
$this->resource = 'country-views';
return $this->fetch_stats( $args );
}
/**
* Get site's views by location.
*
* @param string $geo_mode The type of location to fetch views for (country, region, city).
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_views_by_location( $geo_mode, $args = array() ) {
$this->resource = sprintf( 'location-views/%s', $geo_mode );
return $this->fetch_stats( $args );
}
/**
* Get site's followers.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/followers/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_followers( $args = array() ) {
$this->resource = 'followers';
return $this->fetch_stats( $args );
}
/**
* Get site's comment followers.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/comment-followers/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_comment_followers( $args = array() ) {
$this->resource = 'comment-followers';
return $this->fetch_stats( $args );
}
/**
* Get site's publicize follower counts.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/publicize/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_publicize_followers( $args = array() ) {
$this->resource = 'publicize';
return $this->fetch_stats( $args );
}
/**
* Get search terms used to find the site.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/search-terms/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_search_terms( $args = array() ) {
$this->resource = 'search-terms';
return $this->fetch_stats( $args );
}
/**
* Get the total number of views for each post.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/views/posts/
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_total_post_views( $args = array() ) {
if ( $this->is_wpcom_simple ) {
$post_ids = isset( $args['post_ids'] ) ? explode( ',', $args['post_ids'] ) : array();
$escaped_post_ids = implode( ',', array_map( 'esc_sql', $post_ids ) );
$number_of_days = isset( $args['num'] ) ? absint( $args['num'] ) : 1;
// It's the same function used in WPCOM simple.
// @phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
$end_date = $args['end'] ?? date( 'Y-m-d' );
$stats = $this->fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids );
$post_views = $stats['-'] ?? array();
$posts = array_map(
function ( $post_id ) use ( $post_views ) {
return array(
'ID' => $post_id,
'views' => $post_views[ $post_id ] ?? 0,
);
},
$post_ids
);
return array( 'posts' => $posts );
}
$this->resource = 'views/posts';
return $this->fetch_stats( $args );
}
/**
* Get the number of visits for the site.
*
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_visits( $args = array() ) {
$this->resource = 'visits';
return $this->fetch_stats( $args );
}
/**
* Get streaks for the site.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/streak/
*
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_streak( $args = array() ) {
$this->resource = 'streak';
return $this->fetch_stats( $args );
}
/**
* Get the highlights for the site.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/highlights/
*
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_highlights( $args = array() ) {
$this->resource = 'highlights';
return $this->fetch_stats( $args );
}
/**
* Get the number of visits for the site.
*
* @param array $args Optional query parameters.
* @return array|WP_Error
*/
public function get_insights( $args = array() ) {
$this->resource = 'insights';
return $this->fetch_stats( $args );
}
/**
* Build WPCOM REST API endpoint.
*
* @return string
*/
protected function build_endpoint() {
$resource = ltrim( $this->resource, '/' );
return sprintf( '/sites/%d/stats/%s', Jetpack_Options::get_option( 'id' ), $resource );
}
/**
* Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes.
*
* @param array $args Optional query parameters.
*
* @return array|WP_Error
*/
protected function fetch_stats( $args = array() ) {
$endpoint = $this->build_endpoint();
$api_version = self::STATS_REST_API_VERSION;
$cache_key = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ) ) ) );
$transient_name = self::STATS_CACHE_TRANSIENT_PREFIX . $cache_key;
$stats_cache = get_transient( $transient_name );
if ( $stats_cache ) {
$time = key( $stats_cache );
$data = $stats_cache[ $time ]; // WP_Error or string (JSON encoded object).
if ( is_wp_error( $data ) ) {
return $data;
}
return array_merge( array( 'cached_at' => $time ), (array) json_decode( $data, true ) );
}
$wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
// To reduce size in storage: store with time as key, store JSON encoded data.
$cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats, JSON_UNESCAPED_SLASHES );
/**
* Filters the expiration time for the stats cache.
*
* @module stats
*
* @since 0.10.0
*
* @param int $expiration The expiration time in minutes.
*/
$expiration = apply_filters(
'jetpack_fetch_stats_cache_expiration',
self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
);
set_transient( $transient_name, array( time() => $cached_value ), $expiration );
return $wpcom_stats;
}
/**
* Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes.
*
* Unlike the above function, this caches data in the post meta table. As such,
* it prevents wp_options from blowing up when retrieving views for large numbers
* of posts at the same time.
*
* This function returns valid arrays and WP_Error objects from cache if within the expiration period.
* If the cached entry is malformed or invalid, a refresh is triggered regardless of cache time.
* This self-healing behavior reduces API calls when remote fetch fails, but ensures data validity.
*
* @param array $args Query parameters.
* @param int $post_id Post ID to acquire stats for.
*
* @return array|WP_Error
*/
protected function fetch_post_stats( $args, $post_id ) {
$endpoint = $this->build_endpoint();
$meta_name = '_' . self::STATS_CACHE_TRANSIENT_PREFIX;
$stats_cache = get_post_meta( $post_id, $meta_name, false );
if ( $stats_cache ) {
$data = reset( $stats_cache );
// Check if we have a valid cache structure with a time key.
if ( is_array( $data ) && ! empty( $data ) ) {
$time = key( $data );
// If we have a numeric time, check if cache is still valid.
if ( is_numeric( $time ) ) {
/** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
$expiration = apply_filters(
'jetpack_fetch_stats_cache_expiration',
self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
);
// If within cache period, return cached data after type validation.
if ( ( time() - $time ) < $expiration ) {
$cached_value = $data[ $time ];
// If it's an array or WP_Error, handle appropriately.
if ( is_wp_error( $cached_value ) ) {
return $cached_value;
}
if ( is_array( $cached_value ) ) {
return array_merge( array( 'cached_at' => $time ), $cached_value );
}
// For any other unexpected type, treat as malformed cache.
// Fall through to refresh.
}
}
}
}
// Cache doesn't exist, is expired, or is malformed - refresh it.
return $this->refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name );
}
/**
* Force fetch stats from WPCOM, and always update cache.
*
* This function will cache the result regardless of whether the fetch succeeds
* or fails. This ensures that failed requests are also cached, reducing the
* frequency of API calls when the remote service is experiencing issues.
*
* @param string $endpoint The stats endpoint.
* @param array $args The query arguments.
* @param int $post_id The post ID.
* @param string $meta_name The meta name.
*
* @return array|WP_Error
*/
protected function refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ) {
$wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
// Always cache the result, even if it's an error or empty.
update_post_meta( $post_id, $meta_name, array( time() => $wpcom_stats ) );
return $wpcom_stats;
}
/**
* Fetches stats data from WPCOM.
*
* @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
* @param string $endpoint The stats endpoint.
* @param array $args The query arguments.
* @return array|WP_Error
*/
protected function fetch_remote_stats( $endpoint, $args ) {
if ( is_array( $args ) && ! empty( $args ) ) {
$endpoint .= '?' . http_build_query( $args );
}
$response = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION, array( 'timeout' => 20 ) );
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response_body ) ) {
return is_wp_error( $response ) ? $response : new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );
}
return json_decode( $response_body, true );
}
/**
* Fetch the stats when executed in WPCOM Simple.
*
* @param string $end_date The end date.
* @param int $number_of_days The number of days.
* @param string $escaped_post_ids The escaped post ids.
*
* @return array
*/
protected function fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids ) {
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 );
}
/**
* Convert stats array to object after sanity checking the array is valid.
*
* @since 0.11.0
*
* @param array $stats_array The stats array.
* @return WP_Error|object|null
*/
public function convert_stats_array_to_object( $stats_array ) {
if ( is_wp_error( $stats_array ) ) {
return $stats_array;
}
$encoded_array = wp_json_encode( $stats_array, JSON_UNESCAPED_SLASHES );
if ( ! $encoded_array ) {
return new WP_Error( 'stats_encoding_error', 'Failed to encode stats array' );
}
return json_decode( $encoded_array );
}
}
@@ -0,0 +1,121 @@
<?php
/**
* The Stats XMLRPC Provider class
*
* @package automattic/jetpack-stats
*/
namespace Automattic\Jetpack\Stats;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Modules;
/**
* Stats XMLRPC Provider.
*
* Adds additional methods to the WordPress XML-RPC API for handling Stats specific features.
*
* @since 0.1.0
*/
class XMLRPC_Provider {
/**
* Singleton XMLRPC_Provider instance.
*
* @var XMLRPC_Provider
**/
private static $instance = null;
/**
* Private XMLRPC_Provider constructor.
*
* Use the XMLRPC_Provider::init() method to get an instance.
*/
private function __construct() {
add_filter( 'jetpack_xmlrpc_unauthenticated_methods', array( $this, 'xmlrpc_methods' ), 10, 3 );
}
/**
* Initialize class and get back a singleton instance.
*
* @param bool $new_instance Force create new instance.
*
* @return XMLRPC_Provider
*/
public static function init( $new_instance = false ) {
if ( null === self::$instance || $new_instance ) {
self::$instance = new XMLRPC_Provider();
}
return self::$instance;
}
/**
* Adds additional methods to the WordPress xmlrpc API for handling Stats specific features.
*
* @param array $methods The Jetpack API methods.
*
* @return array
*/
public function xmlrpc_methods( $methods ) {
if ( ! ( new Connection_Manager() )->is_connected() ) {
return $methods;
}
if ( ! ( new Modules() )->is_active( 'stats' ) ) {
return $methods;
}
$methods['jetpack.getBlog'] = array( $this, 'get_blog' );
return $methods;
}
/**
* Stats Get Blog.
*
* @return array
*/
public function get_blog() {
$home = wp_parse_url( trailingslashit( get_option( 'home' ) ) );
$blog = array(
'host' => $home['host'],
'path' => $home['path'],
'blogname' => get_option( 'blogname' ),
'blogdescription' => get_option( 'blogdescription' ),
'siteurl' => get_option( 'siteurl' ),
'gmt_offset' => get_option( 'gmt_offset' ),
'timezone_string' => get_option( 'timezone_string' ),
'stats_version' => Main::STATS_VERSION,
'stats_api' => 'jetpack',
'page_on_front' => get_option( 'page_on_front' ),
'permalink_structure' => get_option( 'permalink_structure' ),
'category_base' => get_option( 'category_base' ),
'tag_base' => get_option( 'tag_base' ),
);
$blog = array_merge( Options::get_options(), $blog );
unset( $blog['roles'], $blog['blog_id'] );
add_filter( 'esc_html', array( $this, 'filter_esc_html_check_if_string' ), 10, 2 );
$blog = map_deep( $blog, 'esc_html' );
remove_filter( 'esc_html', array( $this, 'filter_esc_html_check_if_string' ) );
return $blog;
}
/**
* Make sure we are only escaping html if the input is a string.
* Used for `esc_html` filter-hook.
*
* @param string $safe_text The output after esc_html has been applied.
* @param mixed $text The initial input.
* @return mixed
*/
public function filter_esc_html_check_if_string( $safe_text, $text ) {
if ( is_string( $text ) ) {
return $safe_text;
}
return $text;
}
}