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,385 @@
# 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.16.6 - 2026-06-17
### Fixed
- Stop minting throwaway anonymous IDs for events from clients without a persisted visitor cookie (e.g. UA-spoofing crawlers hitting add-to-cart), which inflated session counts. Such events are now skipped instead. [#65792]
## 0.16.5 - 2026-06-02
### Fixed
- Guard cart hook handlers against missing cart item data to prevent PHP warnings. [#64963]
## 0.16.4 - 2026-05-11
### Changed
- Update package dependencies. [#64040]
### Fixed
- Skip server-side analytics events from cookie-less contexts (REST API, XMLRPC, cron, WP-CLI) to prevent inflated session counts. [#64686]
## [0.16.3] - 2026-04-06
### Changed
- Update dependencies. [#46383]
## [0.16.2] - 2026-03-30
### Changed
- Update package dependencies. [#47799]
### Fixed
- Ensure store_admin property reports 1 for admin users in REST API tracking requests. [#47731]
## [0.16.1] - 2026-03-23
### Changed
- Update package dependencies. [#47684]
### Fixed
- Disable batched analytics requests when a SOCKS proxy is configured, falling back to wp*remote_get() which respects WP_PROXY*\* settings. [#47707]
## [0.16.0] - 2026-03-16
### Changed
- Handle proxy requests at MU plugin stage and exit early for faster response time. [#46757]
- Update dependencies. [#47472]
## [0.15.7] - 2026-03-09
### Changed
- Switch to Native TypeScript compiler based on Go. [#47375]
### Fixed
- Fix TypeScipt type errors detected by `tsgo`. [#47423]
## [0.15.6] - 2026-02-26
### Changed
- Update package dependencies. [#47300]
## [0.15.5] - 2026-02-23
### Changed
- Update package dependencies. [#47173]
## [0.15.4] - 2026-02-16
### Changed
- Update package dependencies. [#47099]
## [0.15.3] - 2026-02-10
### Changed
- Update package dependencies. [#47002]
- Update tracking classes to work without WooCommerce dependency. [#46756]
## [0.15.2] - 2026-02-02
### Changed
- Update package dependencies. [#46854]
## [0.15.1] - 2026-01-26
### Changed
- Update dependencies. [#46383]
## [0.15.0] - 2026-01-23
### Added
- Add woocommerce_analytics_auto_install_proxy_speed_module filter to control auto-installation of proxy speed module mu-plugin. [#46719]
## [0.14.0] - 2026-01-19
### Added
- Add tablet device detection to the 'device' event property, distinguishing between mobile, tablet, and desktop devices. [#46631]
### Changed
- Update package dependencies. [#46647]
## [0.13.4] - 2026-01-12
### Changed
- Update package dependencies. [#46456]
### Fixed
- Fix product_purchase event not tracking for shortcode checkout due to incorrect order ID type check. [#46467]
## [0.13.3] - 2025-12-22
### Changed
- Update dependencies. [#46381]
## [0.13.2] - 2025-12-15
### Changed
- Update dependencies. [#46271]
## [0.13.1] - 2025-12-08
### Fixed
- Ensure proper flags are used with `json_encode()`. [#46092]
## [0.13.0] - 2025-12-01
### Changed
- Added Beacon API support and replaced @wordpress/api-fetch with native fetch. [#45891]
- Update package dependencies. [#46143]
### Fixed
- Fix proxy speed module installation using WP_Filesystem API with proper error handling. [#45801]
## [0.12.2] - 2025-11-24
### Fixed
- Improve compatibility with old WooCommerce versions. [#46003]
## [0.12.1] - 2025-11-19
### Fixed
- Fix compatibility issue with WooCommerce older than 9.7.0. [#45988]
## [0.12.0] - 2025-11-17
### Changed
- Implement batched pixel requests for improved performance when sending tracking events. [#45869]
- Update package dependencies. [#45915] [#45958]
## [0.11.0] - 2025-11-03
### Changed
- Enable ClickHouse by default. [#45696] [#45723]
- Update package dependencies. [#45652]
### Fixed
- Fix data handling with invalid prop names. [#45544]
- Phan: Address PhanRedundantCondition, PhanRedundantArrayValuesCall, and PhanPluginRedundantAssignment violations. [#45681]
- Set dynamic webpack public path for split assets. [#45654]
## [0.10.1] - 2025-10-27
### Changed
- Enhance JSON encoding by adding `JSON_HEX_TAG` and `JSON_UNESCAPED_SLASHES` options. [#45393]
- Ensure anonymous ID is set. [#45547]
### Fixed
- Fix call to undefined method `WC_Tracks::get_server_details()`. [#45394]
- Use existing bot detection from Device Detection package to skip event recording in analytics tracking. [#45552]
## [0.10.0] - 2025-10-02
### Added
- Implement WP Consent API Integration [#45323]
### Changed
- Update package dependencies. [#45334]
## [0.9.2] - 2025-09-30
### Fixed
- Prevent PHP error when WC_Tracks class is not available. [#45330]
- Prevent PHP warnings when array key is undefined. [#45330]
## [0.9.1] - 2025-09-29
### Changed
- Internal updates.
## [0.9.0] - 2025-09-25
### Added
- Add experimental API-based event tracking. [#45279]
- Add IP-based visitor tracking as fallback when proxy tracking is enabled and cookies are unavailable. [#45279]
- Add proxy speed module to enhance proxy API performance. [#45243]
- Implement client-side analytics tracking. [#45268]
### Changed
- Update build scripts for production. [#45296]
## [0.8.0] - 2025-09-22
### Added
- Implement on-site pixel API endpoint with POST support. [#45227]
## [0.7.0] - 2025-09-19
### Added
- Add server-side tracking. [#45208]
### Changed
- Implement server-side direct event delivery. [#45209]
## [0.6.2] - 2025-09-15
### Changed
- Internal updates.
## [0.6.1] - 2025-09-01
### Fixed
- Fix duplicate product purchase events on order confirmation page. [#44973]
## [0.6.0] - 2025-08-18
### Changed
- Replace URL-based landing page tracking with breadcrumb-based hierarchical page tracking. [#44789]
### Fixed
- Fix PHP warnings for `undefined` array keys and `null` object properties. [#44808]
## [0.5.0] - 2025-08-13
### Added
- Add session engagement tracking. [#44729]
- Track page views. [#44729]
### Changed
- Add clickhouse param to record events. [#44729]
- Set expiration time for session cookie to 30 mins or midnight UTC. [#44729]
### Fixed
- Ensure update cart is triggered. [#44729]
- Set woocommerceanalytics_session. [#44729]
## [0.4.9] - 2025-08-04
### Changed
- Performance: Improve processing of blocks during Analytics gathering. [#44544]
## [0.4.8] - 2025-06-16
### Fixed
- Prevent PHP warning in checkout view. [#43890]
## [0.4.7] - 2025-05-05
### Fixed
- Catch PHP error if null param is errantly passed by third-party code. [#43346]
## [0.4.6] - 2025-04-28
### Changed
- Internal updates.
## [0.4.5] - 2025-03-24
### Changed
- Internal updates.
## [0.4.4] - 2025-03-12
### Changed
- Internal updates.
## [0.4.3] - 2025-03-10
### Changed
- Internal updates.
## [0.4.2] - 2025-02-24
### Changed
- Update dependencies.
## [0.4.1] - 2025-01-09
### Fixed
- Temporarily disable setcookie to avoid caching issues. [#40937]
## [0.4.0] - 2025-01-06
### Added
- Add Search Event & landing Page support. [#40698]
## [0.3.1] - 2024-12-25
### Fixed
- Fix fatal when WC()->cart returns null. [#40729]
## [0.3.0] - 2024-12-23
### Changed
- Add common props, more events and bug fixing. [#40562]
## [0.2.0] - 2024-11-18
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [0.1.13] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [0.1.12] - 2024-10-29
### Changed
- Internal updates.
## [0.1.11] - 2024-09-23
### Changed
- Update dependencies.
## [0.1.10] - 2024-09-10
### Fixed
- Check whether `\WC_Install::STORE_ID_OPTION` is defined before attempting to use it, for compatibility with WooCommerce <8.4.0. [#39306]
## [0.1.9] - 2024-09-09
### Added
- Add Store ID property in common woocommerce analytics properties. [#38857]
## [0.1.8] - 2024-08-26
### Changed
- Updated package dependencies. [#39004]
## [0.1.7] - 2024-06-26
### Removed
- Remove use of `gutenberg_get_block_template()`. Its replacement has been in WP Core since 5.8. [#38015]
## [0.1.6] - 2024-05-20
### Fixed
- Customer creation: avoid PHP warnings when other plugins hook into customer creation process and return malformed user data. [#37440]
## [0.1.5] - 2024-05-06
### Changed
- Ensure the package can only be initialized once. [#37154]
## [0.1.4] - 2024-04-15
### Changed
- Internal updates.
## [0.1.3] - 2024-04-08
### Fixed
- Fixed a JavaScript error when accessing the Shortcode checkout with WooCommerce Analytics enable. [#36560]
## [0.1.2] - 2024-03-25
### Changed
- Internal updates.
## [0.1.1] - 2024-03-18
### Changed
- Internal updates.
## 0.1.0 - 2024-03-12
### Added
- General: add main classes to the package. [#35756]
- Initial version. [#35754]
### Fixed
- Avoid any issues when the package is loaded in an mu-plugin. [#36287]
- Fix namespace issue with WooCommerce class reference. [#35857]
- General: bail early when WooCommerce is not active. [#36278]
[0.16.3]: https://github.com/Automattic/woocommerce-analytics/compare/v0.16.2...v0.16.3
[0.16.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.16.1...v0.16.2
[0.16.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.16.0...v0.16.1
[0.16.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.7...v0.16.0
[0.15.7]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.6...v0.15.7
[0.15.6]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.5...v0.15.6
[0.15.5]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.4...v0.15.5
[0.15.4]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.3...v0.15.4
[0.15.3]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.2...v0.15.3
[0.15.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.1...v0.15.2
[0.15.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.15.0...v0.15.1
[0.15.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.13.4...v0.14.0
[0.13.4]: https://github.com/Automattic/woocommerce-analytics/compare/v0.13.3...v0.13.4
[0.13.3]: https://github.com/Automattic/woocommerce-analytics/compare/v0.13.2...v0.13.3
[0.13.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.13.1...v0.13.2
[0.13.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.13.0...v0.13.1
[0.13.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.12.2...v0.13.0
[0.12.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.12.1...v0.12.2
[0.12.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.10.1...v0.11.0
[0.10.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.10.0...v0.10.1
[0.10.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.9.2...v0.10.0
[0.9.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.6.2...v0.7.0
[0.6.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.9...v0.5.0
[0.4.9]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.8...v0.4.9
[0.4.8]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.7...v0.4.8
[0.4.7]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.13...v0.2.0
[0.1.13]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.12...v0.1.13
[0.1.12]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.11...v0.1.12
[0.1.11]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.10...v0.1.11
[0.1.10]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.9...v0.1.10
[0.1.9]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Automattic/woocommerce-analytics/compare/v0.1.0...v0.1.1
@@ -0,0 +1 @@
"use strict";(self.webpackChunk_automattic_woocommerce_analytics=self.webpackChunk_automattic_woocommerce_analytics||[]).push([[252],{252:(c,o,t)=>{function n(c){jQuery(".woocommerce-MyAccount-navigation-link--customer-logout").on("click",function(){c("my_account_tab_click",{tab:"logout"})})}t.r(o),t.d(o,{initListeners:()=>n})}}]);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => '8d8bb846cc19ac858460');
@@ -0,0 +1,82 @@
{
"name": "@automattic/woocommerce-analytics",
"description": "WooCommerce Analytics package to track frontend events",
"homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/php/woocommerce-analytics",
"bugs": {
"url": "https://github.com/woocommerce/woocommerce/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce.git",
"directory": "packages/php/woocommerce-analytics"
},
"license": "GPL-2.0-or-later",
"author": "Automattic",
"scripts": {
"build": "pnpm run clean && webpack",
"build:composer-package": "bash tasks/build-package.sh",
"changelog": "XDEBUG_MODE=off composer install --quiet && composer exec -- changelogger",
"build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build",
"clean": "rm -rf build/",
"test": "jest --passWithNoTests",
"test-coverage": "pnpm run test --coverage",
"test:php": "composer test-php",
"test:php:ci": "pnpm test:php",
"build:ci": "pnpm run typecheck && pnpm run build-production",
"typecheck": "tsc --noEmit",
"postinstall": "XDEBUG_MODE=off composer install --quiet",
"watch": "pnpm build --watch"
},
"dependencies": {
"debug": "4.4.3"
},
"devDependencies": {
"@babel/core": "7.25.7",
"@babel/preset-env": "7.25.7",
"@babel/preset-typescript": "7.25.7",
"@jest/globals": "29.5.x",
"@types/jest": "29.5.x",
"@types/jquery": "3.5.33",
"@wordpress/browserslist-config": "next",
"@wordpress/dependency-extraction-webpack-plugin": "next",
"babel-loader": "9.2.x",
"jest": "29.5.x",
"typescript": "5.7.x",
"webpack": "5.97.x",
"webpack-cli": "5.1.x"
},
"config": {
"ci": {
"tests": [
{
"name": "PHP: 8.1 WP: latest",
"testType": "unit:php",
"command": "test:php:ci",
"changes": [
"composer.json",
"composer.lock",
"**/*.php"
],
"events": [
"pull_request",
"push"
]
},
{
"name": "JS build and typecheck",
"command": "build:ci",
"changes": [
"package.json",
"src/client/**/*.ts",
"tsconfig.json",
"webpack.config.js"
],
"events": [
"pull_request",
"push"
]
}
]
}
}
}
@@ -0,0 +1,150 @@
<?php
/**
* REST API: WC_Analytics_Tracking_Proxy class
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
defined( 'ABSPATH' ) || exit;
/**
* Class to handle tracking events via the REST API
*
* @since 0.7.0
*/
class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'woocommerce-analytics/v1';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'track';
/**
* Register the routes for tracking.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'track_events' ),
'permission_callback' => '__return_true', // no need to check permissions
'schema' => array( $this, 'get_public_item_schema' ),
),
)
);
}
/**
* Track events.
*
* @param \WP_REST_Request $request Full data about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function track_events( $request ) {
// Check consent before processing any events
if ( ! Consent_Manager::has_analytics_consent() ) {
return new \WP_REST_Response(
array(
'success' => true,
'message' => 'Events skipped due to lack of analytics consent',
'results' => array(),
),
200
);
}
$events = $request->get_json_params();
if ( ! is_array( $events ) || ( isset( $events['event_name'] ) ) ) {
// If $events is a single event (associative array), wrap it in an array.
$events = array( $events );
}
$results = array();
$has_errors = false;
foreach ( $events as $index => $event ) {
// Validate event structure.
if ( empty( $event ) || ! is_array( $event ) ) {
$results[ $index ] = array(
'success' => false,
'error' => 'Invalid event format',
);
$has_errors = true;
continue;
}
// Validate event name and properties.
$event_name = $event['event_name'] ?? null;
$properties = $event['properties'] ?? array();
if ( ! $event_name || ! is_array( $properties ) ) {
$results[ $index ] = array(
'success' => false,
'error' => 'Missing event_name or invalid properties',
);
$has_errors = true;
continue;
}
$result = WC_Analytics_Tracking::record_event( $event_name, $properties );
if ( is_wp_error( $result ) ) {
$results[ $index ] = array(
'success' => false,
'error' => $result->get_error_message(),
);
$has_errors = true;
continue;
}
$results[ $index ] = array( 'success' => true );
}
$response_data = array(
'success' => ! $has_errors,
'results' => $results,
);
return new \WP_REST_Response( $response_data, $has_errors ? 207 : 200 );
}
/**
* Get the schema for tracking events.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'tracking_events',
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'event_name' => array(
'type' => 'string',
),
'properties' => array(
'type' => 'object',
),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}
@@ -0,0 +1,33 @@
<?php
/**
* Consent management for WP Consent API integration
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
/**
* Manages consent checking for WooCommerce Analytics
*/
class Consent_Manager {
/**
* WP Consent API's consent type we check for analytics tracking
*/
const WP_CONSENT_API_STATISTICS_TYPE = 'statistics';
/**
* Check if user has consent for analytics tracking
*
* @return bool
*/
public static function has_analytics_consent() {
if ( ! function_exists( 'wp_has_consent' ) ) {
// If WP Consent API is not available, default to true for backward compatibility
return true;
}
return \wp_has_consent( self::WP_CONSENT_API_STATISTICS_TYPE );
}
}
@@ -0,0 +1,66 @@
<?php
/**
* Features class for WooCommerce Analytics.
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
/**
* Features class for WooCommerce Analytics.
*/
class Features {
/**
* Check if proxy tracking is enabled.
*
* @return bool
*/
public static function is_proxy_tracking_enabled() {
/**
* Filter to enable/disable experimental proxy tracking for WooCommerce Analytics
*
* @since 0.9.0
*
* @param bool $enabled Whether proxy tracking is enabled. Default false.
*/
return apply_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled', false );
}
/**
* Check if ClickHouse is enabled.
*
* @return bool
*/
public static function is_clickhouse_enabled() {
/**
* Filter to enable/disable ClickHouse event tracking.
*
* @module woocommerce-analytics
*
* @since 0.5.0
*
* @param bool $enabled Whether ClickHouse event tracking is enabled.
*/
return apply_filters( 'woocommerce_analytics_clickhouse_enabled', false );
}
/**
* Check if auto-installation of the proxy speed module MU-plugin is enabled.
*
* @return bool
*/
public static function is_proxy_speed_module_enabled() {
/**
* Filter to control auto-installation of the proxy speed module mu-plugin.
*
* When this filter returns false, the mu-plugin file can't be added automatically.
*
* @since 0.15.0
*
* @param bool $auto_install Whether to auto-install the mu-plugin. Default false.
*/
return apply_filters( 'woocommerce_analytics_auto_install_proxy_speed_module', false );
}
}
@@ -0,0 +1,252 @@
<?php
/**
* Events tracked on the My Account page.
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
/**
* Filters and Actions added to My Account pages to perform analytics
*/
class My_Account {
use Woo_Analytics_Trait;
/**
* Constructor.
*/
public function init_hooks() {
add_action( 'woocommerce_account_content', array( $this, 'track_tabs' ) );
add_action( 'woocommerce_customer_save_address', array( $this, 'track_save_address' ), 10, 2 );
add_action( 'wp', array( $this, 'track_add_payment_method' ) );
add_action( 'wp', array( $this, 'track_delete_payment_method' ) );
add_action( 'woocommerce_save_account_details', array( $this, 'track_save_account_details' ) );
add_filter( 'woocommerce_my_account_my_orders_actions', array( $this, 'add_initiator_prop_to_my_account_action_links' ) );
add_action( 'woocommerce_cancelled_order', array( $this, 'track_order_cancel_event' ), 10, 0 );
add_action( 'before_woocommerce_pay', array( $this, 'track_order_pay_event' ) );
add_action( 'woocommerce_before_account_orders', array( $this, 'add_initiator_prop_to_order_urls' ), 9 );
add_filter( 'query_vars', array( $this, 'add_initiator_param_to_query_vars' ) );
}
/**
* Track my account tabs, we only trigger an event if a tab is viewed.
*
* We also track other events here, like order number clicks, order action clicks,
* address clicks, payment method add and delete.
*/
public function track_tabs() {
global $wp;
// WooCommerce keeps a map of my-account endpoints keys and their custom permalinks.
$core_endpoints = WC()->query->get_query_vars();
if ( ! empty( $wp->query_vars ) ) {
foreach ( $wp->query_vars as $key => $value ) {
// we skip pagename.
if ( 'pagename' === $key ) {
continue;
}
// When no permalink is set, the first page is page_id, so we skip it.
if ( 'page_id' === $key ) {
continue;
}
// We don't want to track our own analytics params.
if ( '_wca_initiator' === $key ) {
continue;
}
if ( isset( $core_endpoints['view-order'] ) && $core_endpoints['view-order'] === $key && is_numeric( $value ) ) {
$initiator = get_query_var( '_wca_initiator' );
if ( 'number' === $initiator ) {
$this->enqueue_event( 'my_account_order_number_click' );
continue;
}
if ( 'action' === $initiator ) {
$this->enqueue_event( 'my_account_order_action_click', array( 'action' => 'view' ) );
continue;
}
}
if ( isset( $core_endpoints['edit-address'] ) && $core_endpoints['edit-address'] === $key && in_array( $value, array( 'billing', 'shipping' ), true ) ) {
$refer = wp_get_referer();
if ( $refer === wc_get_endpoint_url( 'edit-address', $value ) ) {
// It means we're likely coming from the same page after a failed save and don't want to retrigger the address click event.
continue;
}
$this->enqueue_event( 'my_account_address_click', array( 'address' => $value ) );
continue;
}
if ( isset( $core_endpoints['add-payment-method'] ) && $core_endpoints['add-payment-method'] === $key ) {
$this->enqueue_event( 'my_account_payment_add' );
continue;
}
if ( isset( $core_endpoints['edit-address'] ) && $core_endpoints['edit-address'] ) {
$refer = wp_get_referer();
if ( $refer === wc_get_endpoint_url( 'edit-address', 'billing' ) || $refer === wc_get_endpoint_url( 'edit-address', 'shipping' ) ) {
// It means we're likely coming from the edit page save and don't want to retrigger the page view event.
continue;
}
}
/**
* The main dashboard view has page as key, so we rename it.
*/
if ( 'page' === $key ) {
$key = 'dashboard';
}
/**
* If a custom permalink is used for one of the pages, query_vars will have 2 keys, the custom permalink and the core endpoint key.
* To avoid triggering the event twice, we skip the core one and only track the custom one.
* Tracking the custom endpoint is safer than hoping the duplicated, redundant core endpoint is always present.
*/
if ( isset( $core_endpoints[ $key ] ) && $core_endpoints[ $key ] !== $key ) {
continue;
}
/**
* $core_endpoints is an array of core_permalink => custom_permalink,
* query_vars gives us the custom_permalink, but we want to track it as core_permalink.
*/
if ( array_search( $key, $core_endpoints, true ) ) {
$key = array_search( $key, $core_endpoints, true );
}
$this->enqueue_event( 'my_account_page_view', array( 'tab' => $key ) );
}
}
}
/**
* Track address save events, this can only come from the my account page.
*
* @param int $customer_id The customer id.
* @param string $load_address The address type (billing, shipping).
*/
public function track_save_address( $customer_id, $load_address ) {
WC_Analytics_Tracking::record_event( 'my_account_address_save', array( 'address' => $load_address ) );
}
/**
* Track payment method add events, this can only come from the my account page.
*/
public function track_add_payment_method() {
if ( isset( $_POST['woocommerce_add_payment_method'] ) && isset( $_POST['payment_method'] ) ) {
$nonce_value = wc_get_var( $_REQUEST['woocommerce-add-payment-method-nonce'], wc_get_var( $_REQUEST['_wpnonce'], '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
if ( ! wp_verify_nonce( $nonce_value, 'woocommerce-add-payment-method' ) ) {
return;
}
WC_Analytics_Tracking::record_event( 'my_account_payment_save' );
return;
}
}
/**
* Track payment method delete events.
*/
public function track_delete_payment_method() {
global $wp;
if ( isset( $wp->query_vars['delete-payment-method'] ) ) {
WC_Analytics_Tracking::record_event( 'my_account_payment_delete' );
return;
}
}
/**
* Track order cancel events.
*/
public function track_order_cancel_event() {
if ( isset( $_GET['_wca_initiator'] ) && ( isset( $_GET['_wpnonce'] ) && wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), 'woocommerce-cancel_order' ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
WC_Analytics_Tracking::record_event( 'my_account_order_action_click', array( 'action' => 'cancel' ) );
}
}
/**
* Track order pay events.
*/
public function track_order_pay_event() {
if ( isset( $_GET['_wca_initiator'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended
WC_Analytics_Tracking::record_event( 'my_account_order_action_click', array( 'action' => 'pay' ) );
}
}
/**
* Track account details save events, this can only come from the my account page.
*/
public function track_save_account_details() {
WC_Analytics_Tracking::record_event( 'my_account_details_save' );
}
/**
* Add referrer prop to my account action links
*
* @param array $actions My account action links.
* @return array
*/
public function add_initiator_prop_to_my_account_action_links( $actions ) {
foreach ( $actions as $key => $action ) {
if ( ! isset( $action['url'] ) ) {
continue;
}
// Check if the action key is view, pay, or cancel.
if ( ! in_array( $key, array( 'view', 'pay', 'cancel' ), true ) ) {
continue;
}
$url = add_query_arg( array( '_wca_initiator' => 'action' ), $action['url'] );
$actions[ $key ]['url'] = $url;
}
return $actions;
}
/**
* Add an initiator prop to the order url.
*
* The get_view_order_url is used in a lot of places,
* so we want to limit it just to my account page.
*/
public function add_initiator_prop_to_order_urls() {
add_filter(
'woocommerce_get_view_order_url',
function ( $url ) {
return add_query_arg( array( '_wca_initiator' => 'number' ), $url );
},
10,
1
);
add_filter(
'woocommerce_get_endpoint_url',
function ( $url, $endpoint ) {
if ( 'edit-address' === $endpoint ) {
return add_query_arg( array( '_wca_initiator' => 'action' ), $url );
}
return $url;
},
10,
2
);
}
/**
* Add initiator to query vars
*
* @param array $query_vars Query vars.
* @return array
*/
public function add_initiator_param_to_query_vars( $query_vars ) {
$query_vars[] = '_wca_initiator';
return $query_vars;
}
}
@@ -0,0 +1,339 @@
<?php
/**
* Pixel Builder for WooCommerce Analytics
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
use WP_Error;
/**
* Pixel Builder class - handles pixel URL construction.
*/
class Pixel_Builder {
/**
* Tracks pixel URL.
*
* @var string
*/
const TRACKS_PIXEL_URL = 'https://pixel.wp.com/t.gif';
/**
* ClickHouse pixel URL.
*
* @var string
*/
const CH_PIXEL_URL = 'https://pixel.wp.com/w.gif';
/**
* Browser type identifier for server-side tracking.
*
* @var string
*/
const BROWSER_TYPE = 'php-agent';
/**
* Event name regex pattern.
* Format: prefix_eventname (e.g., woocommerceanalytics_checkout_started)
*
* @var string
*/
const EVENT_NAME_REGEX = '/^(([a-z0-9]+)_){1}([a-z0-9_]+)$/';
/**
* Property name regex pattern.
* Format: lowercase letters/underscores, starting with letter or underscore.
*
* @var string
*/
const PROP_NAME_REGEX = '/^[a-z_][a-z0-9_]*$/';
/**
* Build a timestamp representing milliseconds since 1970-01-01.
*
* @return string A string representing a timestamp.
*/
public static function build_timestamp() {
$ts = round( microtime( true ) * 1000 );
return number_format( $ts, 0, '', '' );
}
/**
* Add request timestamp and nocache parameter to pixel URL.
* Should be called just before the HTTP request.
*
* @param string $pixel Pixel URL.
* @return string Pixel URL with request timestamp and URL terminator.
*/
public static function add_request_timestamp_and_nocache( $pixel ) {
return $pixel . '&_rt=' . self::build_timestamp() . '&_=_';
}
/**
* Build a Tracks pixel URL from properties.
*
* @param array $properties Event properties.
* @return string|WP_Error Pixel URL on success, WP_Error on failure.
*/
public static function build_tracks_url( $properties ) {
$validated = self::validate_and_sanitize( $properties );
if ( is_wp_error( $validated ) ) {
return $validated;
}
return self::TRACKS_PIXEL_URL . '?' . http_build_query( $validated );
}
/**
* Build a ClickHouse pixel URL from properties.
*
* @param array $properties Event properties.
* @return string|WP_Error Pixel URL on success, WP_Error on failure.
*/
public static function build_ch_url( $properties ) {
$validated = self::validate_and_sanitize( $properties );
if ( is_wp_error( $validated ) ) {
return $validated;
}
return self::CH_PIXEL_URL . '?' . http_build_query( $validated );
}
/**
* Validate and sanitize event properties.
*
* @param array $properties Event properties.
* @return array|WP_Error Validated properties on success, WP_Error on failure.
*/
public static function validate_and_sanitize( $properties ) {
// Required: event name.
if ( empty( $properties['_en'] ) ) {
return new WP_Error( 'invalid_event', 'A valid event must be specified via `_en`', 400 );
}
// Validate event name format.
if ( ! self::event_name_is_valid( $properties['_en'] ) ) {
return new WP_Error( 'invalid_event_name', 'A valid event name must be specified.' );
}
// Delete non-routable IP addresses (geoip would discard these anyway).
if ( isset( $properties['_via_ip'] ) && preg_match( '/^192\.168|^10\./', $properties['_via_ip'] ) ) {
unset( $properties['_via_ip'] );
}
// Add browser type for server-side tracking.
$properties['browser_type'] = self::BROWSER_TYPE;
// Ensure timestamp exists.
if ( ! isset( $properties['_ts'] ) ) {
$properties['_ts'] = self::build_timestamp();
}
// Validate property names.
foreach ( array_keys( $properties ) as $key ) {
if ( '_en' === $key ) {
continue;
}
if ( ! self::prop_name_is_valid( $key ) ) {
return new WP_Error( 'invalid_prop_name', 'A valid prop name must be specified: ' . $key );
}
}
// Sanitize array values to prevent bracket notation in URL serialization.
return self::sanitize_property_values( $properties );
}
/**
* Check if event name is valid.
*
* @param string $name Event name.
* @return bool True if valid, false otherwise.
*/
public static function event_name_is_valid( $name ) {
return (bool) preg_match( self::EVENT_NAME_REGEX, $name );
}
/**
* Check if a property name is valid.
*
* @param string $name Property name.
* @return bool True if valid, false otherwise.
*/
public static function prop_name_is_valid( $name ) {
return (bool) preg_match( self::PROP_NAME_REGEX, $name );
}
/**
* Sanitize property values for URL serialization.
*
* Converts array values to appropriate formats to prevent http_build_query()
* from creating bracket notation (e.g., prop[0], prop[1]) which violates
* the property name regex.
*
* @param array $properties Event properties.
* @return array Sanitized properties.
*/
private static function sanitize_property_values( $properties ) {
foreach ( $properties as $key => $value ) {
if ( ! is_array( $value ) ) {
continue;
}
if ( empty( $value ) ) {
// Empty array becomes empty string.
$properties[ $key ] = '';
continue;
}
// Check if array is indexed (not associative) and contains only scalar values.
$is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
$has_scalar_only = ! array_filter(
$value,
function ( $item ) {
return is_array( $item ) || is_object( $item );
}
);
if ( $is_indexed_array && $has_scalar_only ) {
// Indexed arrays with scalar values: join as comma string.
$properties[ $key ] = implode( ',', array_map( 'strval', $value ) );
continue;
}
// Associative arrays or nested arrays become JSON strings.
$encoded = wp_json_encode( $value, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES );
$properties[ $key ] = ( false === $encoded ) ? '' : $encoded;
}
return $properties;
}
/**
* Check if a SOCKS proxy is configured.
*
* The Requests library doesn't support SOCKS proxies, so we need to fall back
* to wp_remote_get() which respects WordPress proxy settings.
*
* @return bool True if a SOCKS proxy is configured.
*/
private static function is_socks_proxy_configured() {
if ( ! defined( 'WP_PROXY_HOST' ) || ! is_string( WP_PROXY_HOST ) || '' === WP_PROXY_HOST ) {
return false;
}
return self::is_socks_proxy_host( (string) WP_PROXY_HOST );
}
/**
* Check if a proxy host string indicates a SOCKS proxy.
*
* @param string $proxy_host The proxy host value.
* @return bool True if the host indicates a SOCKS proxy.
*/
public static function is_socks_proxy_host( $proxy_host ) {
$proxy_host = strtolower( $proxy_host );
return 0 === strpos( $proxy_host, 'socks5://' )
|| 0 === strpos( $proxy_host, 'socks4://' )
|| 0 === strpos( $proxy_host, 'socks://' );
}
/**
* Send pixel requests using batched non-blocking HTTP calls.
*
* Uses Requests library's request_multiple() for parallel execution via curl_multi.
*
* @param array $pixels Array of pixel URLs to send.
* @return bool True on success.
*/
public static function send_pixels_batched( $pixels ) {
if ( empty( $pixels ) ) {
return true;
}
// Check if batching is supported.
// Note: WpOrg\Requests\Requests doesn't support SOCKS proxies, so we fall back
// to individual wp_remote_get() requests which respect WP_PROXY_* settings.
$can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
|| ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
$can_batch = $can_batch && ! self::is_socks_proxy_configured();
if ( ! $can_batch ) {
// Fallback to individual requests.
foreach ( $pixels as $pixel ) {
self::send_pixel( $pixel );
}
return true;
}
// Add timestamp and nocache to all pixels.
$pixels_to_send = array();
foreach ( $pixels as $pixel ) {
$pixels_to_send[] = self::add_request_timestamp_and_nocache( $pixel );
}
// Build request array for batch sending.
$requests = array();
$options = array(
'blocking' => false, // Non-blocking mode.
'timeout' => 1,
);
foreach ( $pixels_to_send as $pixel ) {
$requests[] = array(
'url' => $pixel,
'headers' => array(),
'data' => array(),
'type' => 'GET',
);
}
try {
if ( class_exists( 'WpOrg\Requests\Requests' ) ) {
\WpOrg\Requests\Requests::request_multiple( $requests, $options );
} elseif ( class_exists( 'Requests' ) ) {
\Requests::request_multiple( $requests, $options ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions.requestsDeprecated
}
} catch ( \Exception $e ) {
// Log error but don't break the site - tracking pixels should fail gracefully.
$error_message = 'WooCommerce Analytics: Batch pixel request failed - ' . $e->getMessage();
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( $error_message, array( 'source' => 'woocommerce-analytics' ) );
} else {
// Fallback for MU-plugin stage when WooCommerce logger is not available.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $error_message );
}
return false;
}
return true;
}
/**
* Send a single pixel request.
*
* @param string $pixel Pixel URL.
* @return bool True on success.
*/
public static function send_pixel( $pixel ) {
$pixel = self::add_request_timestamp_and_nocache( $pixel );
wp_remote_get(
$pixel,
array(
'blocking' => false,
'redirection' => 2,
'httpversion' => '1.1',
'timeout' => 1,
)
);
return true;
}
}
@@ -0,0 +1,718 @@
<?php
/**
* General store tracking actions.
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
use Automattic\Jetpack\Constants;
use WC_Order;
use WC_Product;
/**
* Filters and Actions added to Store pages to perform analytics.
*/
class Universal {
/**
* Trait to handle common analytics functions.
*/
use Woo_Analytics_Trait;
/**
* Constructor.
*/
public function init_hooks() {
$this->find_cart_checkout_content_sources();
$this->additional_blocks_on_cart_page = $this->get_additional_blocks_on_page( 'cart' );
$this->additional_blocks_on_checkout_page = $this->get_additional_blocks_on_page( 'checkout' );
// Capture search
add_action( 'template_redirect', array( $this, 'capture_search_query' ), 11 );
// Capture cart events.
add_action( 'woocommerce_add_to_cart', array( $this, 'capture_add_to_cart' ), 10, 6 );
add_action( 'woocommerce_after_cart_item_quantity_update', array( $this, 'capture_cart_quantity_update' ), 10, 4 );
add_action( 'woocommerce_cart_item_removed', array( $this, 'capture_remove_from_cart' ), 10, 2 );
add_filter( 'woocommerce_cart_item_remove_link', array( $this, 'remove_from_cart_attributes' ), 10, 2 );
// Checkout.
// Send events after checkout template (shortcode).
add_action( 'woocommerce_after_checkout_form', array( $this, 'checkout_process' ) );
// Send events after checkout block.
add_action( 'woocommerce_blocks_enqueue_checkout_block_scripts_after', array( $this, 'checkout_process' ) );
// order processed.
add_action( 'woocommerce_checkout_order_processed', array( $this, 'order_process' ), 10, 1 );
add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'order_process' ), 10, 1 );
add_filter( 'woocommerce_checkout_posted_data', array( $this, 'save_checkout_post_data' ), 10, 1 );
add_action( 'woocommerce_created_customer', array( $this, 'capture_created_customer' ), 10, 2 );
add_action( 'woocommerce_created_customer', array( $this, 'capture_post_checkout_created_customer' ), 10, 2 );
// single product page view.
add_action( 'woocommerce_after_single_product', array( $this, 'capture_product_view' ) );
// order confirmed page view
add_action( 'woocommerce_thankyou', array( $this, 'capture_order_confirmation_view' ), 10, 1 );
// checkout page view
add_action( 'wp_footer', array( $this, 'capture_checkout_view' ), 11 );
// cart page view
add_action( 'wp_footer', array( $this, 'capture_cart_view' ), 11 );
// Enqueue events to track.
add_action( 'wp_footer', array( $this, 'inject_analytics_data' ), 999 );
}
/**
* Inject analytics data into the window object
*/
public function inject_analytics_data() {
$is_clickhouse_enabled = Features::is_clickhouse_enabled();
$is_proxy_tracking_enabled = Features::is_proxy_tracking_enabled();
// When proxy tracking is enabled, we don't need to send the common properties to the client.
$common_properties = $is_proxy_tracking_enabled ? array() : $this->get_common_properties();
?>
<script type="text/javascript">
(function() {
window.wcAnalytics = window.wcAnalytics || {};
const wcAnalytics = window.wcAnalytics;
// Set the assets URL for webpack to find the split assets.
wcAnalytics.assets_url = '<?php echo esc_url( plugins_url( '../build/', __DIR__ . '/class-woocommerce-analytics.php' ) ); ?>';
// Set the REST API tracking endpoint URL.
<?php
$track_endpoint = rest_url( 'woocommerce-analytics/v1/track' );
// Include the WP REST nonce for logged-in users so WordPress can identify
// the current user via cookie auth. This is needed for the store_admin
// property detection, not for endpoint authorization.
if ( is_user_logged_in() ) {
$track_endpoint = add_query_arg( '_wpnonce', wp_create_nonce( 'wp_rest' ), $track_endpoint );
}
?>
wcAnalytics.trackEndpoint = <?php echo wp_json_encode( esc_url_raw( $track_endpoint ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
// Set common properties for all events.
wcAnalytics.commonProps = <?php echo wp_json_encode( $common_properties, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
// Set the event queue.
wcAnalytics.eventQueue = <?php echo wp_json_encode( WC_Analytics_Tracking::get_event_queue(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
// Features.
wcAnalytics.features = {
ch: <?php echo $is_clickhouse_enabled ? 'true' : 'false'; ?>,
sessionTracking: <?php echo $is_clickhouse_enabled ? 'true' : 'false'; ?>,
proxy: <?php echo $is_proxy_tracking_enabled ? 'true' : 'false'; ?>,
};
wcAnalytics.breadcrumbs = <?php echo wp_json_encode( $this->get_breadcrumb_titles(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;
// Page context flags.
wcAnalytics.pages = {
isAccountPage: <?php echo is_account_page() ? 'true' : 'false'; ?>,
isCart: <?php echo is_cart() ? 'true' : 'false'; ?>,
};
})();
</script>
<?php
}
/**
* Capture remove from cart events in mini-cart and cart blocks
*
* @param string $cart_item_key The cart item removed.
* @param \WC_Cart $cart The cart.
*
* @return void
*/
public function capture_remove_from_cart( $cart_item_key, $cart ) {
$item = $cart->removed_cart_contents[ $cart_item_key ] ?? null;
if ( ! is_array( $item ) || ! isset( $item['product_id'], $item['quantity'] ) ) {
return;
}
WC_Analytics_Tracking::record_event(
'remove_from_cart',
$this->get_cart_checkout_event_properties(
array(
'pi' => (int) $item['product_id'],
'pq' => (int) $item['quantity'],
)
)
);
}
/**
* Capture remove/add from cart events using the Cart Controller
*
* @param string $cart_item_key The cart item updated.
* @param int $quantity Contains the new quantity of the item.
* @param int $old_quantity Contains the old quantity of the item.
* @param \WC_Cart $cart The cart.
*
* @return void
*/
public function capture_cart_quantity_update( $cart_item_key, $quantity, $old_quantity, $cart ) {
if ( ! isset( $cart->cart_contents[ $cart_item_key ]['product_id'] ) ) {
return;
}
$product_id = $cart->cart_contents[ $cart_item_key ]['product_id'];
if ( $quantity > $old_quantity ) {
WC_Analytics_Tracking::record_event(
'add_to_cart',
$this->get_cart_checkout_event_properties(
array(
'pi' => $product_id,
'pq' => $quantity,
)
)
);
$this->lock_add_to_cart_events = true;
return;
}
if ( $quantity < $old_quantity ) {
WC_Analytics_Tracking::record_event(
'remove_from_cart',
$this->get_cart_checkout_event_properties(
array(
'pi' => $product_id,
'pq' => $quantity,
)
)
);
return;
}
}
/**
* Adds the product ID to the remove product link (for use by remove_from_cart above) if not present
*
* @param string $url Full HTML a tag of the link to remove an item from the cart.
* @param string $key Unique Key ID for a cart item.
*
* @return string
*/
public function remove_from_cart_attributes( $url, $key ) {
if ( str_contains( $url, 'data-product_id' ) ) {
return $url;
}
$item = WC()->cart->get_cart_item( $key );
$product = $item['data'];
$new_attributes = sprintf(
'" data-product_id="%s">',
esc_attr( $product->get_id() )
);
$url = str_replace( '">', $new_attributes, $url );
return $url;
}
/**
* Get the selected shipping option for a cart item. If the name cannot be found in the options table, the method's
* ID will be used.
*
* @param string $cart_item_key the cart item key.
*
* @return mixed|bool
*/
public function get_shipping_option_for_item( $cart_item_key ) {
$packages = wc()->shipping()->get_packages();
$selected_options = wc()->session->get( 'chosen_shipping_methods' );
if ( ! is_array( $packages ) || ! is_array( $selected_options ) ) {
return false;
}
foreach ( $packages as $package_id => $package ) {
if ( ! isset( $package['contents'] ) || ! is_array( $package['contents'] ) ) {
return false;
}
foreach ( $package['contents'] as $package_item ) {
if ( ! isset( $package_item['key'] ) || $package_item['key'] !== $cart_item_key || ! isset( $selected_options[ $package_id ] ) ) {
continue;
}
$selected_rate_id = $selected_options[ $package_id ];
$method_key_id = sanitize_text_field( str_replace( ':', '_', $selected_rate_id ) );
$option_name = 'woocommerce_' . $method_key_id . '_settings';
$option_value = get_option( $option_name );
$title = '';
if ( is_array( $option_value ) && isset( $option_value['title'] ) ) {
$title = $option_value['title'];
}
if ( ! $title ) {
return $selected_rate_id;
}
return $title;
}
}
return false;
}
/**
* On the Checkout page, trigger an event for each product in the cart
*/
public function checkout_process() {
global $post;
$checkout_page_id = wc_get_page_id( 'checkout' );
$cart = WC()->cart->get_cart();
$is_in_checkout_page = isset( $post->ID ) && $checkout_page_id === $post->ID ? 'Yes' : 'No';
$session = WC()->session;
if ( is_object( $session ) ) {
$session->set( 'checkout_page_used', 'Yes' === $is_in_checkout_page );
$session->save_data();
}
foreach ( $cart as $cart_item_key => $cart_item ) {
/**
* This filter is already documented in woocommerce/templates/cart/cart.php
*/
$product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key );
if ( ! $product || ! $product instanceof WC_Product ) {
continue;
}
$data = $this->get_cart_checkout_shared_data();
$data['from_checkout'] = $is_in_checkout_page;
if ( ! empty( $data['products'] ) ) {
unset( $data['products'] );
}
if ( ! empty( $data['shipping_options_count'] ) ) {
unset( $data['shipping_options_count'] );
}
$data['pq'] = $cart_item['quantity'];
$this->enqueue_event( 'product_checkout', $this->get_cart_checkout_event_properties( $data ), $product->get_id() );
}
}
/**
* After the order processed, fire an event for each item in the order
*
* @param int|string|WC_Order $order_id_or_order Order Id or Order object.
*/
public function order_process( $order_id_or_order ) {
$order = wc_get_order( $order_id_or_order );
if ( ! $order ) {
return;
}
$payment_option = $order->get_payment_method();
if ( is_object( WC()->session ) ) {
$create_account = true === WC()->session->get( 'wc_checkout_createaccount_used' ) ? 'Yes' : 'No';
$checkout_page_used = true === WC()->session->get( 'checkout_page_used' ) ? 'Yes' : 'No';
} else {
$create_account = 'No';
$checkout_page_used = 'No';
}
$delayed_account_creation = ucfirst( get_option( 'woocommerce_enable_delayed_account_creation', 'Yes' ) );
$guest_checkout = $order->get_user() ? 'No' : 'Yes';
$express_checkout = 'null';
// When the payment option is woocommerce_payment
// See if Google Pay or Apple Pay was used.
if ( 'woocommerce_payments' === $payment_option ) {
$payment_option_title = $order->get_payment_method_title();
if ( 'Google Pay (WooCommerce Payments)' === $payment_option_title ) {
$express_checkout = array( 'google_pay' );
} elseif ( 'Apple Pay (WooCommerce Payments)' === $payment_option_title ) {
$express_checkout = array( 'apple_pay' );
}
}
$checkout_page_contains_checkout_block = '0';
$checkout_page_contains_checkout_shortcode = '0';
$order_source = $order->get_created_via();
if ( 'store-api' === $order_source ) {
$checkout_page_contains_checkout_block = '1';
} elseif ( 'checkout' === $order_source ) {
$checkout_page_contains_checkout_shortcode = '1';
}
// loop through products in the order and queue a purchase event.
foreach ( $order->get_items() as $order_item ) {
// @phan-suppress-next-line PhanUndeclaredMethod -- Checked before being called. See also https://github.com/phan/phan/issues/1204.
$product_id = is_callable( array( $order_item, 'get_product_id' ) ) ? $order_item->get_product_id() : -1;
$order_items = $order->get_items();
$order_items_count = 0;
if ( is_array( $order_items ) ) {
$order_items_count = count( $order_items );
}
$order_coupons = $order->get_coupons();
$order_coupons_count = 0;
if ( is_array( $order_coupons ) ) {
$order_coupons_count = count( $order_coupons );
}
WC_Analytics_Tracking::record_event(
'product_purchase',
$this->get_cart_checkout_event_properties(
array(
'oi' => $order->get_order_number(),
'pi' => $product_id,
'pq' => $order_item->get_quantity(),
'payment_option' => $payment_option,
'create_account' => $create_account,
'guest_checkout' => $guest_checkout,
'delayed_account_creation' => $delayed_account_creation,
'express_checkout' => $express_checkout,
'coupon_used' => $order_coupons_count,
'products_count' => $order_items_count,
'order_value' => $order->get_subtotal(),
'order_total' => $order->get_total(),
'total_discount' => $order->get_discount_total(),
'total_taxes' => $order->get_total_tax(),
'total_shipping' => $order->get_shipping_total(),
'from_checkout' => $checkout_page_used,
'checkout_page_contains_checkout_block' => $checkout_page_contains_checkout_block,
'checkout_page_contains_checkout_shortcode' => $checkout_page_contains_checkout_shortcode,
)
)
);
}
}
/**
* Gets the inner blocks of a block.
*
* @param array $inner_blocks The inner blocks.
*
* @return array
*/
private function get_inner_blocks( $inner_blocks ) {
$block_names = array();
if ( ! empty( $inner_blocks['blockName'] ) ) {
$block_names[] = $inner_blocks['blockName'];
}
if ( isset( $inner_blocks['innerBlocks'] ) && is_array( $inner_blocks['innerBlocks'] ) ) {
$block_names = array_merge( $block_names, $this->get_inner_blocks( $inner_blocks['innerBlocks'] ) );
}
return $block_names;
}
/**
* Track adding items to the cart.
*
* @param string $cart_item_key Cart item key.
* @param int $product_id Product added to cart.
* @param int $quantity Quantity added to cart.
* @param int $variation_id Product variation.
* @param array $variation Variation attributes..
* @param array $cart_item_data Other cart data.
*/
public function capture_add_to_cart( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( $this->lock_add_to_cart_events ) {
return;
}
WC_Analytics_Tracking::record_event(
'add_to_cart',
$this->get_cart_checkout_event_properties(
array(
'pi' => $product_id,
'pq' => $quantity,
)
)
);
}
/**
* Get the event properties for the cart and checkout events.
*
* @param array $event_properties Event properties.
*/
public function get_cart_checkout_event_properties( $event_properties = array() ) {
if ( isset( $event_properties['pq'] ) ) {
$event_properties['pq'] = 0 === $event_properties['pq'] ? 1 : $event_properties['pq'];
$event_properties['pq'] = (string) $event_properties['pq'];
}
$product = isset( $event_properties['pi'] ) ? wc_get_product( $event_properties['pi'] ) : null;
$product_details = $product instanceof WC_Product ? $this->get_product_details( $product ) : array();
$checkout_cart_details = array(
'template_used' => $this->cart_checkout_templates_in_use ? '1' : '0',
'additional_blocks_on_cart_page' => $this->additional_blocks_on_cart_page,
'additional_blocks_on_checkout_page' => $this->additional_blocks_on_checkout_page,
'order_value' => $this->get_cart_subtotal(),
'order_total' => $this->get_cart_total(),
'total_tax' => $this->get_cart_taxes(),
'total_discount' => $this->get_total_discounts(),
'total_shipping' => $this->get_cart_shipping_total(),
'products_count' => $this->get_cart_items_count(),
);
$cart_checkout_info = $this->get_cart_checkout_info();
$event_properties = array_merge( $product_details, $checkout_cart_details, $cart_checkout_info, $event_properties ); // event properties should be last to allow for overrides
return $event_properties;
}
/**
* Save create account post data to be used in $this->order_process.
*
* @param array|null $data Post data from the checkout page.
*
* @return array|null
*/
public function save_checkout_post_data( ?array $data ) {
if ( is_object( WC()->session ) && ! empty( $data['createaccount'] ) ) {
WC()->session->set( 'wc_checkout_createaccount_used', true );
WC()->session->save_data();
}
return $data;
}
/**
* Capture the create account event. Similar to save_checkout_post_data but works with Store API.
*
* @param int $customer_id Customer ID.
* @param array $new_customer_data New customer data.
*/
public function capture_created_customer( $customer_id, $new_customer_data ) {
$session = WC()->session;
if (
is_object( $session )
&& is_array( $new_customer_data )
&& ! empty( $new_customer_data['source'] )
) {
if ( str_contains( $new_customer_data['source'], 'store-api' ) ) {
$session->set( 'wc_checkout_createaccount_used', true );
$session->save_data();
}
}
}
/**
* Capture the post checkout create account event.
*
* @param int $customer_id Customer ID.
* @param array $new_customer_data New customer data.
*/
public function capture_post_checkout_created_customer( $customer_id, $new_customer_data ) {
if (
is_array( $new_customer_data )
&& ! empty( $new_customer_data['source'] )
&& str_contains( $new_customer_data['source'], 'delayed-account-creation' )
) {
$checkout_page_used = true === WC()->session->get( 'checkout_page_used' ) ? 'Yes' : 'No';
$checkout_page_contains_checkout_block = '1';
$checkout_page_contains_checkout_shortcode = '0';
$this->enqueue_event(
'post_account_creation',
$this->get_cart_checkout_event_properties(
array(
'from_checkout' => $checkout_page_used,
'checkout_page_contains_checkout_block' => $checkout_page_contains_checkout_block,
'checkout_page_contains_checkout_shortcode' => $checkout_page_contains_checkout_shortcode,
)
)
);
}
}
/**
* Capture a search event.
*/
public function capture_search_query() {
if ( is_search() ) {
global $wp_query;
$this->enqueue_event(
'search',
array(
'search_query' => $wp_query->get( 's' ),
'qty' => $wp_query->found_posts,
)
);
}
}
/**
* Track the cart page view
*/
public function capture_cart_view() {
global $post;
$cart_page_id = wc_get_page_id( 'cart' );
$is_cart = $cart_page_id && is_page( $cart_page_id )
|| wc_post_content_has_shortcode( 'woocommerce_cart' )
|| has_block( 'woocommerce/cart', $post )
|| apply_filters( 'woocommerce_is_cart', false )
|| Constants::is_defined( 'WOOCOMMERCE_CART' )
|| is_cart();
if ( ! $is_cart ) {
return;
}
$this->enqueue_event(
'cart_view',
$this->get_cart_checkout_event_properties(
$this->get_cart_checkout_shared_data()
)
);
}
/**
* Track a product page view
*/
public function capture_product_view() {
global $product;
if ( ! $product instanceof WC_Product ) {
return;
}
$this->enqueue_event(
'product_view',
array(),
$product->get_id()
);
}
/**
* Track the order confirmation page view
*/
public function capture_order_confirmation_view() {
$order_id = absint( get_query_var( 'order-received' ) );
if ( ! $order_id ) {
return;
}
if ( ! is_order_received_page() ) {
return;
}
$order = wc_get_order( $order_id );
$order_source = $order->get_created_via();
$checkout_page_contains_checkout_block = '0';
$checkout_page_contains_checkout_shortcode = '0';
if ( 'store-api' === $order_source ) {
$checkout_page_contains_checkout_block = '1';
} elseif ( 'checkout' === $order_source ) {
$checkout_page_contains_checkout_shortcode = '1';
}
$coupons = $order->get_coupons();
$coupon_used = 0;
if ( is_countable( $coupons ) ) {
$coupon_used = count( $coupons ) ? 1 : 0;
}
if ( is_object( WC()->session ) ) {
$create_account = true === WC()->session->get( 'wc_checkout_createaccount_used' ) ? 'Yes' : 'No';
$checkout_page_used = true === WC()->session->get( 'checkout_page_used' ) ? 'Yes' : 'No';
} else {
$create_account = 'No';
$checkout_page_used = 'No';
}
$delayed_account_creation = ucfirst( get_option( 'woocommerce_enable_delayed_account_creation', 'Yes' ) );
$this->enqueue_event(
'order_confirmation_view',
$this->get_cart_checkout_event_properties(
array(
'coupon_used' => $coupon_used,
'create_account' => $create_account,
'express_checkout' => 'null', // TODO: not solved yet.
'guest_checkout' => $order->get_customer_id() ? 'No' : 'Yes',
'delayed_account_creation' => $delayed_account_creation,
'oi' => $order->get_id(),
'order_value' => $order->get_subtotal(),
'order_total' => $order->get_total(),
'products_count' => $order->get_item_count(),
'total_discount' => $order->get_discount_total(),
'total_shipping' => $order->get_shipping_total(),
'total_tax' => $order->get_total_tax(),
'payment_option' => $order->get_payment_method(),
'products' => $this->format_items_to_json( $order->get_items() ),
'order_note' => $order->get_customer_note(),
'shipping_option' => $order->get_shipping_method(),
'from_checkout' => $checkout_page_used,
'checkout_page_contains_checkout_block' => $checkout_page_contains_checkout_block,
'checkout_page_contains_checkout_shortcode' => $checkout_page_contains_checkout_shortcode,
)
)
);
}
/**
* Track the checkout page view
*/
public function capture_checkout_view() {
global $post;
if ( ! $post instanceof \WP_Post ) {
return;
}
$checkout_page_id = wc_get_page_id( 'checkout' );
$is_checkout = $checkout_page_id && is_page( $checkout_page_id )
|| wc_post_content_has_shortcode( 'woocommerce_checkout' )
|| has_block( 'woocommerce/checkout', $post )
|| apply_filters( 'woocommerce_is_checkout', false )
|| Constants::is_defined( 'WOOCOMMERCE_CHECKOUT' );
if ( ! $is_checkout ) {
return;
}
$is_in_checkout_page = isset( $post->ID ) && $checkout_page_id === $post->ID ? 'Yes' : 'No';
$checkout_page_contains_checkout_block = '0';
$checkout_page_contains_checkout_shortcode = '1';
$session = WC()->session;
if ( is_object( $session ) ) {
$session->set( 'checkout_page_used', true );
$session->save_data();
$draft_order_id = $session->get( 'store_api_draft_order', 0 );
if ( $draft_order_id ) {
$checkout_page_contains_checkout_block = '1';
$checkout_page_contains_checkout_shortcode = '0';
}
}
// Order received page is also a checkout page, so we need to bail out if we are on that page.
if ( is_order_received_page() ) {
return;
}
$this->enqueue_event(
'checkout_view',
$this->get_cart_checkout_event_properties(
array_merge(
$this->get_cart_checkout_shared_data(),
array(
'from_checkout' => $is_in_checkout_page,
'checkout_page_contains_checkout_block' => $checkout_page_contains_checkout_block,
'checkout_page_contains_checkout_shortcode' => $checkout_page_contains_checkout_shortcode,
)
)
)
);
}
}
@@ -0,0 +1,614 @@
<?php
/**
* WooCommerce Analytics Tracking for tracking frontend events
*
* This class is designed to work without WooCommerce dependencies,
* enabling it to run at the MU-plugin stage without loading WooCommerce to optimize performance.
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
use Automattic\Jetpack\Device_Detection;
use Automattic\Jetpack\Device_Detection\User_Agent_Info;
use WP_Error;
/**
* WooCommerce Analytics Tracking class
*/
class WC_Analytics_Tracking {
/**
* Event prefix.
*
* @var string
*/
const PREFIX = 'woocommerceanalytics_';
/**
* Option name for storing daily salt data.
*
* @var string
*/
const DAILY_SALT_OPTION = 'woocommerce_analytics_daily_salt';
/**
* Event queue.
*
* @var array
*/
protected static $event_queue = array();
/**
* Batch pixel queue for batched requests.
*
* @var array
*/
private static $pixel_batch_queue = array();
/**
* Whether the shutdown hook has been registered.
*
* @var bool
*/
private static $shutdown_hook_registered = false;
/**
* Cached user IP address for the current request.
*
* @var string|null
*/
private static $cached_ip = null;
/**
* Cached visitor ID for the current request.
*
* @var string|null
*/
private static $cached_visitor_id = null;
/**
* Record an event in Tracks and ClickHouse (If enabled).
*
* @param string $event_name The name of the event.
* @param array $event_properties Custom properties to send with the event.
*
* @return bool|WP_Error True on emit or deliberate skip (no consent, bot UA,
* or cookie-less context); WP_Error if pixel firing failed.
*/
public static function record_event( $event_name, $event_properties = array() ) {
// Check consent before recording any event.
if ( ! Consent_Manager::has_analytics_consent() ) {
return true; // Skip recording.
}
// Skip recording if the request is coming from a bot.
if ( User_Agent_Info::is_bot() ) {
return true;
}
// Skip events that arrive without a stable visitor id (e.g. no tk_ai cookie); see get_visitor_id().
if ( empty( self::get_visitor_id() ) ) {
return true;
}
$prefixed_event_name = self::PREFIX . $event_name;
$properties = self::get_properties( $prefixed_event_name, $event_properties );
// Record Tracks event.
$tracks_error = null;
$tracks_result = self::record_tracks_event( $properties );
if ( is_wp_error( $tracks_result ) ) {
$tracks_error = $tracks_result;
}
// Record ClickHouse event, if applicable.
$ch_error = null;
if ( Features::is_clickhouse_enabled() || ( isset( $properties['ch'] ) && 1 === (int) $properties['ch'] ) ) {
$properties['ch'] = 1;
$ch_result = self::record_ch_event( $properties );
if ( is_wp_error( $ch_result ) ) {
$ch_error = $ch_result;
}
}
// If both failed, return the Tracks error (primary), else the CH error, else true.
if ( $tracks_error ) {
return $tracks_error;
}
if ( $ch_error ) {
return $ch_error;
}
return true;
}
/**
* Queue an event in the event queue which will be processed on the page load in client-side analytics.
*
* @param string $event_name The name of the event.
* @param array $properties The event properties.
*/
public static function add_event_to_queue( $event_name, $properties = array() ) {
self::$event_queue[] = array(
'eventName' => $event_name,
'props' => $properties,
);
}
/**
* Get the event queue.
*
* @return array The event queue.
*/
public static function get_event_queue() {
return self::$event_queue;
}
/**
* Record an event in Tracks.
*
* @param array $properties Properties to send with the event.
* @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
*/
private static function record_tracks_event( $properties = array() ) {
$pixel_url = Pixel_Builder::build_tracks_url( $properties );
if ( is_wp_error( $pixel_url ) ) {
return $pixel_url;
}
return self::record_pixel_url( $pixel_url );
}
/**
* Record a ClickHouse event.
*
* @param array $properties The event properties.
* @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
*/
private static function record_ch_event( $properties ) {
$pixel_url = Pixel_Builder::build_ch_url( $properties );
if ( is_wp_error( $pixel_url ) ) {
return $pixel_url;
}
return self::record_pixel_url( $pixel_url );
}
/**
* Record a pixel URL using batching.
*
* @param string $pixel_url The pixel URL to record.
* @return bool|WP_Error True for success or WP_Error if the event pixel could not be fired.
*/
private static function record_pixel_url( $pixel_url ) {
if ( empty( $pixel_url ) ) {
return new WP_Error( 'invalid_pixel', 'cannot generate tracks pixel for given input', 400 );
}
// Check if batching is supported.
$can_batch = ( class_exists( 'WpOrg\Requests\Requests' ) && method_exists( 'WpOrg\Requests\Requests', 'request_multiple' ) )
|| ( class_exists( 'Requests' ) && method_exists( 'Requests', 'request_multiple' ) );
if ( $can_batch ) {
// Queue the pixel and send on shutdown.
self::queue_pixel_for_batch( $pixel_url );
} else {
// Send immediately as batching is not supported.
Pixel_Builder::send_pixel( $pixel_url );
}
return true;
}
/**
* Queue a pixel URL for batch sending.
*
* @param string $pixel The pixel URL to queue.
*/
private static function queue_pixel_for_batch( $pixel ) {
self::$pixel_batch_queue[] = $pixel;
// Register shutdown hook once.
if ( ! self::$shutdown_hook_registered ) {
add_action( 'shutdown', array( __CLASS__, 'send_batched_pixels' ), 20 );
self::$shutdown_hook_registered = true;
}
}
/**
* Send all queued pixels using batched non-blocking requests.
* This runs on the shutdown hook to batch all requests together.
*
* Uses Pixel_Builder for the actual sending via Requests library.
*/
public static function send_batched_pixels() {
if ( empty( self::$pixel_batch_queue ) ) {
return;
}
// Delegate to Pixel_Builder for batched sending.
Pixel_Builder::send_pixels_batched( self::$pixel_batch_queue );
// Clear the queue.
self::$pixel_batch_queue = array();
}
/**
* Get the common properties for the event.
*
* @return array The common properties.
*/
public static function get_common_properties() {
$blog_user_id = self::get_blog_user_id();
$server_details = self::get_server_details();
$blog_details = self::get_blog_details();
$session_details = self::get_session_details();
$common_properties = array_merge(
array(
'session_id' => $session_details['session_id'] ?? null,
'landing_page' => $session_details['landing_page'] ?? null,
'is_engaged' => $session_details['is_engaged'] ?? null,
'ui' => $blog_user_id,
'blog_id' => $blog_details['blog_id'] ?? null,
'store_id' => $blog_details['store_id'] ?? null,
'url' => $blog_details['url'] ?? null,
'woo_version' => $blog_details['wc_version'] ?? null,
'wp_version' => get_bloginfo( 'version' ),
'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
'device' => self::get_device_type(),
'store_currency' => $blog_details['store_currency'] ?? null,
'timezone' => wp_timezone_string(),
'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
),
$server_details
);
return is_array( $common_properties ) ? $common_properties : array();
}
/**
* Get all properties for the event including filtered and identity properties.
*
* @param string $event_name Event name.
* @param array $event_properties Event specific properties.
* @return array
*/
public static function get_properties( $event_name, $event_properties ) {
$common_properties = self::get_common_properties();
/**
* Allow defining custom event properties in WooCommerce Analytics.
*
* @module woocommerce-analytics
*
* @since 12.5
*
* @param array $all_props Array of event props to be filtered.
*/
$properties = apply_filters( 'jetpack_woocommerce_analytics_event_props', array_merge( $common_properties, $event_properties ), $event_name );
$required_properties = $event_name
? array(
'_en' => $event_name,
'_ts' => Pixel_Builder::build_timestamp(),
'_ut' => 'anon',
'_ui' => self::get_visitor_id(),
)
: array();
$all_properties = array_merge( $properties, $required_properties );
// Convert array values to a comma-separated string and URL-encode them to ensure compatibility with JavaScript's encodeURIComponent() for pixel URL transmission.
foreach ( $all_properties as $key => $value ) {
if ( ! is_array( $value ) ) {
continue;
}
if ( empty( $value ) ) {
$all_properties[ $key ] = '';
continue;
}
$is_indexed_array = array_keys( $value ) === range( 0, count( $value ) - 1 );
if ( $is_indexed_array ) {
$value_string = implode( ',', $value );
$all_properties[ $key ] = rawurlencode( $value_string );
continue;
}
// Serialize non-indexed arrays to JSON strings.
$all_properties[ $key ] = wp_json_encode( $value, JSON_UNESCAPED_SLASHES );
}
return $all_properties;
}
/**
* Get the current user id.
*
* @return int The user ID, or 0 if not logged in.
*/
private static function get_blog_user_id() {
// Ensure cookie constants are defined.
if ( ! defined( 'LOGGED_IN_COOKIE' ) ) {
if ( function_exists( 'wp_cookie_constants' ) ) {
wp_cookie_constants();
} else {
require_once ABSPATH . WPINC . '/default-constants.php';
wp_cookie_constants();
}
}
if ( function_exists( 'get_current_user_id' ) && get_current_user_id() ) {
return get_current_user_id();
}
// Manually validate the logged_in cookie
if ( ! function_exists( 'wp_validate_auth_cookie' ) ) {
require_once ABSPATH . WPINC . '/pluggable.php';
}
$user_id = wp_validate_auth_cookie( '', 'logged_in' );
return $user_id ? (int) $user_id : 0;
}
/**
* Gather details from the request to the server.
*
* This method is now standalone and doesn't rely on WC_Tracks parent class.
*
* @return array Server details.
*/
public static function get_server_details() {
// Sanitization helper - use wc_clean if available, otherwise sanitize_text_field.
$clean = function_exists( 'wc_clean' ) ? 'wc_clean' : 'sanitize_text_field';
$data = array(
'_via_ua' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? $clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
'_via_ip' => self::get_user_ip_address(),
'_lg' => isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ), 0, 5 ) : '',
'_dr' => isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
);
// Build the document location URL.
$uri = isset( $_SERVER['REQUEST_URI'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$host = isset( $_SERVER['HTTP_HOST'] ) ? $clean( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$data['_dl'] = isset( $_SERVER['REQUEST_SCHEME'] ) ? $clean( wp_unslash( $_SERVER['REQUEST_SCHEME'] ) ) . '://' . $host . $uri : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
// Add _via_ref (referrer) for backward compatibility.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$data['_via_ref'] = isset( $_SERVER['HTTP_REFERER'] ) ? $clean( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
return $data;
}
/**
* Get the blog details.
*
* This method is now standalone and doesn't rely on WC_Tracks parent class.
* It still works with WooCommerce when available for additional details.
*
* @return array The blog details.
*/
public static function get_blog_details() {
// Try to get cached blog details.
$blog_details = get_transient( 'wc_analytics_blog_details' );
if ( false !== $blog_details ) {
return $blog_details;
}
// Get Jetpack blog ID if available.
$jetpack_blog_id = null;
if ( class_exists( 'Jetpack_Options' ) ) {
$jetpack_blog_id = \Jetpack_Options::get_option( 'id' );
}
// Get WooCommerce version if available.
// Check WC_VERSION constant first (most reliable), then fall back to option.
if ( defined( 'WC_VERSION' ) ) {
$wc_version = WC_VERSION;
} else {
$wc_version = get_option( 'woocommerce_version', '' );
}
// Get store ID from known option name.
$store_id = get_option( 'woocommerce_store_id', null );
// Get store currency - use WC function if available, otherwise fall back to option.
$store_currency = function_exists( 'get_woocommerce_currency' )
? get_woocommerce_currency()
: get_option( 'woocommerce_currency', 'USD' );
$blog_details = array(
'url' => home_url(),
'blog_lang' => get_locale(),
'blog_id' => $jetpack_blog_id,
'store_id' => $store_id,
'wc_version' => $wc_version,
'store_currency' => $store_currency,
);
// Cache for 1 day.
set_transient( 'wc_analytics_blog_details', $blog_details, DAY_IN_SECONDS );
return $blog_details;
}
/**
* Get the session details as an array
*
* @return array
*/
private static function get_session_details() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is decoded and validated below. We don't need to sanitize the cookie value because we're not outputting it but decoding it as JSON. Sanitization might break the JSON.
$raw_cookie = isset( $_COOKIE['woocommerceanalytics_session'] ) ? wp_unslash( $_COOKIE['woocommerceanalytics_session'] ) : '';
if ( ! $raw_cookie ) {
return array();
}
$decoded = json_decode( rawurldecode( $raw_cookie ), true );
return is_array( $decoded ) ? $decoded : array();
}
/**
* Get the existing stable visitor id: the `tk_ai` cookie, or an IP-based hash when
* proxy tracking is enabled. Returns null otherwise so the caller skips the event.
*
* We never mint a new id here: attributing an event to a brand-new id creates a
* throwaway one-event "visitor" (mostly cookie-less crawlers) that inflates session
* counts. Real browsers already have a `tk_ai` cookie by the time an event fires.
*
* @return string|null Stable visitor id, or null when none is available.
*/
private static function get_visitor_id() {
// Return cached result if available.
if ( null !== self::$cached_visitor_id ) {
return self::$cached_visitor_id;
}
// Prefer the tk_ai cookie if present.
if ( ! empty( $_COOKIE['tk_ai'] ) ) {
self::$cached_visitor_id = sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) );
return self::$cached_visitor_id;
}
// Cron and WP-CLI have no real visitor; never attribute background activity to one.
if ( ( defined( 'DOING_CRON' ) && DOING_CRON )
|| ( defined( 'WP_CLI' ) && WP_CLI )
) {
return null;
}
// Proxy tracking provides a stable id from daily_salt + domain + ip + user_agent.
if ( Features::is_proxy_tracking_enabled() ) {
self::$cached_visitor_id = self::get_ip_based_visitor_id();
return self::$cached_visitor_id;
}
// No stable id arrived with the request. Do not mint one (see method doc).
return null;
}
/**
* Get the user's IP address.
*
* @return string The user's IP address. An empty string if no valid IP address is found.
*/
private static function get_user_ip_address() {
// Return cached IP if available
if ( null !== self::$cached_ip ) {
return self::$cached_ip;
}
$ip_headers = array(
'HTTP_CF_CONNECTING_IP', // Cloudflare specific header.
'HTTP_X_FORWARDED_FOR',
'REMOTE_ADDR',
'HTTP_CLIENT_IP',
);
foreach ( $ip_headers as $header ) {
if ( isset( $_SERVER[ $header ] ) ) {
$ip_list = explode( ',', wp_unslash( $_SERVER[ $header ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
foreach ( $ip_list as $ip_candidate ) {
$ip_candidate = trim( $ip_candidate );
if ( filter_var(
$ip_candidate,
FILTER_VALIDATE_IP,
array( FILTER_FLAG_NO_RES_RANGE, FILTER_FLAG_IPV6 )
) ) {
// Cache the resolved IP
self::$cached_ip = $ip_candidate;
return self::$cached_ip;
}
}
}
}
// Cache empty result
self::$cached_ip = '';
return self::$cached_ip;
}
/**
* Get IP-based visitor ID for proxy tracking mode.
*
* @return string|null
*/
private static function get_ip_based_visitor_id() {
$ip = self::get_user_ip_address();
if ( empty( $ip ) ) {
return null;
}
$salt = self::get_daily_salt();
$url_parts = wp_parse_url( home_url() );
$domain = $url_parts['host'] ?? '';
$user_agent = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
// Create hash from: daily_salt + domain + ip + user_agent
$hash_input = $salt . $domain . $ip . $user_agent;
return substr( hash( 'sha256', $hash_input ), 0, 16 );
}
/**
* Get or generate daily salt for visitor ID hashing.
* Creates a new salt value each day (UTC) for privacy protection.
*
* @return string The daily salt.
*/
private static function get_daily_salt() {
$today = gmdate( 'Y-m-d' ); // UTC date
$salt_data = get_option( self::DAILY_SALT_OPTION );
// Check if salt exists and is still valid for today
if (
is_array( $salt_data )
&& isset( $salt_data['date'] )
&& isset( $salt_data['salt'] )
&& $salt_data['date'] === $today
) {
return $salt_data['salt'];
}
// Generate new salt for today
$new_salt = wp_generate_password( 32, false );
// Store salt with date (no expiration time needed)
$salt_data = array(
'date' => $today,
'salt' => $new_salt,
);
update_option( self::DAILY_SALT_OPTION, $salt_data );
return $new_salt;
}
/**
* Get the device type for the current request.
*
* Uses Jetpack Device Detection to distinguish between mobile phones, tablets, and desktop devices.
*
* @return string 'mobile' for phones, 'tablet' for tablets, 'desktop' otherwise.
*/
private static function get_device_type() {
if ( Device_Detection::is_phone() ) {
return 'mobile';
}
if ( Device_Detection::is_tablet() ) {
return 'tablet';
}
return 'desktop';
}
}
@@ -0,0 +1,667 @@
<?php
/**
* Woo_Analytics_Trait
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic\Woocommerce_Analytics;
use Automattic\Block_Scanner;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection;
use WC_Order_Item;
use WC_Order_Item_Product;
use WC_Payment_Gateway;
use WC_Product;
/**
* Common functionality for WooCommerce Analytics classes.
*/
trait Woo_Analytics_Trait {
/**
* Saves whether the cart/checkout templates are in use based on WC Blocks version.
*
* @var bool true if the templates are in use.
*/
protected $cart_checkout_templates_in_use;
/**
* The content of the cart page or where the cart page is ultimately derived from if using a template.
*
* @var string
*/
protected $cart_content_source = '';
/**
* The content of the checkout page or where the cart page is ultimately derived from if using a template.
*
* @var string
*/
protected $checkout_content_source = '';
/**
* Tracks any additional blocks loaded on the Cart page.
*
* @var array
*/
protected $additional_blocks_on_cart_page;
/**
* Tracks any additional blocks loaded on the Checkout page.
*
* @var array
*/
protected $additional_blocks_on_checkout_page;
/**
* Locks Add to Cart Events Tracking in the current request avoiding duplications.
* i.e. If update_cart and add_to_cart actions happens in the same request.
*
* @var bool If true. Cart events are locked for the current request.
*/
protected $lock_add_to_cart_events = false;
/**
* Format Cart Items or Order Items to an array
*
* @param array|WC_Order_Item[] $items Cart Items or Order Items.
*/
protected function format_items_to_json( $items ) {
$products = array();
foreach ( $items as $item ) {
if ( $item instanceof WC_Order_Item_Product ) {
$product = wc_get_product( $item->get_product_id() );
} else {
$product = $item['data'];
}
if ( ! $product || ! $product instanceof WC_Product ) {
continue;
}
$data = $this->get_product_details( $product );
if ( $item instanceof WC_Order_Item_Product ) {
$data['pq'] = $item->get_quantity();
} else {
$data['pq'] = $item['quantity'];
}
$products[] = $data;
}
return wp_json_encode( $products, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG );
}
/**
* Get Cart/Checkout page view shared data
*/
protected function get_cart_checkout_shared_data() {
$cart = WC()->cart;
$guest_checkout = ucfirst( get_option( 'woocommerce_enable_guest_checkout', 'No' ) );
$create_account = ucfirst( get_option( 'woocommerce_enable_signup_and_login_from_checkout', 'No' ) );
$delayed_account_creation = ucfirst( get_option( 'woocommerce_enable_delayed_account_creation', 'Yes' ) );
$coupons = $cart->get_coupons();
$coupon_used = 0;
if ( is_countable( $coupons ) ) {
$coupon_used = count( $coupons ) ? 1 : 0;
}
$enabled_payment_options = array_filter(
WC()->payment_gateways->get_available_payment_gateways(),
function ( $payment_gateway ) {
if ( ! $payment_gateway instanceof WC_Payment_Gateway ) {
return false;
}
return $payment_gateway->is_available();
}
);
$enabled_payment_options = array_keys( $enabled_payment_options );
$shared_data = array(
'products' => $this->format_items_to_json( $cart->get_cart() ),
'create_account' => $create_account,
'guest_checkout' => $guest_checkout,
'delayed_account_creation' => $delayed_account_creation,
'express_checkout' => 'null', // TODO: not solved yet.
'shipping_options_count' => 'null', // TODO: not solved yet.
'coupon_used' => $coupon_used,
'payment_options' => $enabled_payment_options,
);
return $shared_data;
}
/**
* Gets the content of the cart/checkout page or where the cart/checkout page is ultimately derived from if using a template.
* This method sets the class properties $checkout_content_source and $cart_content_source.
*
* @return void Does not return, but sets class properties.
*/
public function find_cart_checkout_content_sources() {
/**
* The steps we take to find the content are:
* 1. Check the transient, if that contains content and is not expired, return that.
* 2. Check if the cart/checkout templates are in use. If *not in use*, get the content from the pages and
* return it, there is no need to dig further.
* 3. If the templates *are* in use, check if the `page-content-wrapper` block is in use. If so, get the content
* from the pages (same as step 2) and return it.
* 4. If the templates are in use but `page-content-wrapper` is not, then get the content directly from the
* template and return it.
* 5. At the end of each step, assign the found content to the relevant class properties and save them in a
* transient with a 1-day lifespan. This will prevent us from having to do this work on every page load.
*/
$cart_checkout_content_cache_transient_name = 'jetpack_woocommerce_analytics_cart_checkout_content_sources';
$transient_value = get_transient( $cart_checkout_content_cache_transient_name );
if (
false !== $transient_value &&
! empty( $transient_value['checkout_content_source'] ) &&
! empty( $transient_value['cart_content_source'] )
) {
$this->cart_content_source = $transient_value['cart_content_source'];
$this->checkout_content_source = $transient_value['checkout_content_source'];
return;
}
$this->cart_checkout_templates_in_use = wp_is_block_theme()
&& class_exists( '\Automattic\WooCommerce\Blocks\Package' )
// @phan-suppress-current-line UnusedPluginSuppression @phan-suppress-next-line PhanUndeclaredClassMethod -- If the class exists (as of WooCommerce 8.5.0), the method exists. See also: https://github.com/phan/phan/issues/1204
&& version_compare( \Automattic\WooCommerce\Blocks\Package::get_version(), '10.6.0', '>=' );
// Cart/Checkout *pages* are in use if the templates are not in use. Return their content and do nothing else.
if ( ! $this->cart_checkout_templates_in_use ) {
$cart_page = get_post( wc_get_page_id( 'cart' ) );
$checkout_page = get_post( wc_get_page_id( 'checkout' ) );
if ( $cart_page && isset( $cart_page->post_content ) ) {
$this->cart_content_source = $cart_page->post_content;
}
if ( $checkout_page && isset( $checkout_page->post_content ) ) {
$this->checkout_content_source = $checkout_page->post_content;
}
set_transient(
$cart_checkout_content_cache_transient_name,
array(
'cart_content_source' => $this->cart_content_source,
'checkout_content_source' => $this->checkout_content_source,
),
DAY_IN_SECONDS
);
return;
}
// We are in a Block theme - so we need to find out if the templates are being used.
if ( function_exists( 'get_block_template' ) ) {
$checkout_template = get_block_template( 'woocommerce/woocommerce//page-checkout' );
$cart_template = get_block_template( 'woocommerce/woocommerce//page-cart' );
if ( ! $checkout_template ) {
$checkout_template = get_block_template( 'woocommerce/woocommerce//checkout' );
}
if ( ! $cart_template ) {
$cart_template = get_block_template( 'woocommerce/woocommerce//cart' );
}
}
if ( ! empty( $checkout_template->content ) ) {
// Checkout template is in use, but we need to see if the page-content-wrapper is in use, or if the template is being used directly.
$this->checkout_content_source = $checkout_template->content;
$is_using_page_content = str_contains( $checkout_template->content, '<!-- wp:woocommerce/page-content-wrapper {"page":"checkout"}' );
if ( $is_using_page_content ) {
// The page-content-wrapper is in use, so we need to get the page content.
$checkout_page = get_post( wc_get_page_id( 'checkout' ) );
if ( $checkout_page && isset( $checkout_page->post_content ) ) {
$this->checkout_content_source = $checkout_page->post_content;
}
}
}
if ( ! empty( $cart_template->content ) ) {
// Cart template is in use, but we need to see if the page-content-wrapper is in use, or if the template is being used directly.
$this->cart_content_source = $cart_template->content;
$is_using_page_content = str_contains( $cart_template->content, '<!-- wp:woocommerce/page-content-wrapper {"page":"cart"}' );
if ( $is_using_page_content ) {
// The page-content-wrapper is in use, so we need to get the page content.
$cart_page = get_post( wc_get_page_id( 'cart' ) );
if ( $cart_page && isset( $cart_page->post_content ) ) {
$this->cart_content_source = $cart_page->post_content;
}
}
}
set_transient(
$cart_checkout_content_cache_transient_name,
array(
'cart_content_source' => $this->cart_content_source,
'checkout_content_source' => $this->checkout_content_source,
),
DAY_IN_SECONDS
);
}
/**
* Default event properties which should be included with all events.
*
* @return array Array of standard event props.
*/
public function get_common_properties() {
$common_properties = WC_Analytics_Tracking::get_common_properties();
/**
* Allow defining custom event properties in WooCommerce Analytics.
*
* @module woocommerce-analytics
*
* @since 12.5
*
* @param array $properties Array of event props to be filtered.
*/
$properties = apply_filters(
'jetpack_woocommerce_analytics_event_props',
$common_properties
);
return $properties;
}
/**
* Enqueue an event with optional product and custom properties.
*
* @param string $event_name The name of the event to record.
* @param array $properties Optional array of (key => value) event properties.
* @param integer|null $product_id The id of the product relating to the event.
*
* @return void
*/
public function enqueue_event( $event_name, $properties = array(), $product_id = null ) {
// Only set product details if we have a product id.
if ( $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product instanceof WC_Product ) {
return;
}
$product_details = $this->get_product_details( $product );
}
$event_properties = array_merge( $product_details ?? array(), $properties );
WC_Analytics_Tracking::add_event_to_queue( $event_name, $event_properties );
}
/**
* Gather relevant product information
*
* @param \WC_Product $product product.
* @return array
*/
public function get_product_details( $product ) {
return array(
'pi' => $product->get_id(),
'pn' => $product->get_title(),
'pc' => $this->get_product_categories_concatenated( $product ),
'pp' => $product->get_price(),
'pt' => $product->get_type(),
);
}
/**
* Gets product categories or varation attributes as a formatted concatenated string
*
* @param object $product WC_Product.
* @return string
*/
public function get_product_categories_concatenated( $product ) {
if ( ! $product instanceof WC_Product ) {
return '';
}
$variation_data = $product->is_type( 'variation' ) ? wc_get_product_variation_attributes( $product->get_id() ) : '';
if ( is_array( $variation_data ) && ! empty( $variation_data ) ) {
$line = wc_get_formatted_variation( $variation_data, true );
} else {
$out = array();
$categories = get_the_terms( $product->get_id(), 'product_cat' );
if ( is_array( $categories ) ) {
foreach ( $categories as $category ) {
$out[] = $category->name;
}
}
$line = implode( '/', $out );
}
return $line;
}
/**
* Get the current user id
*
* @return string|null
*/
public function get_user_id() {
if ( is_user_logged_in() ) {
$blogid = Jetpack_Connection::get_site_id();
$userid = get_current_user_id();
return $blogid . ':' . $userid;
}
return null;
}
/**
* Gets the IDs of additional blocks on the Cart/Checkout pages or templates.
*
* @param string $cart_or_checkout Whether to get blocks on the cart or checkout page.
* @return array All inner blocks on the page.
*/
public function get_additional_blocks_on_page( $cart_or_checkout = 'cart' ) {
$additional_blocks_on_page_transient_name = 'jetpack_woocommerce_analytics_additional_blocks_on_' . $cart_or_checkout . '_page';
$additional_blocks_on_page = get_transient( $additional_blocks_on_page_transient_name );
if ( false !== $additional_blocks_on_page ) {
return $additional_blocks_on_page;
}
$content = $this->cart_content_source;
if ( 'checkout' === $cart_or_checkout ) {
$content = $this->checkout_content_source;
}
$blocks_to_ignore = array(
'woocommerce/classic-shortcode',
'core/shortcode',
'checkout' === $cart_or_checkout ? 'woocommerce/checkout' : 'woocommerce/cart',
);
$scanner = Block_Scanner::create( $content );
if ( ! $scanner ) {
return array();
}
$found_blocks = array();
$ignored_block_depth = 0; // Count how many ignored blocks we're nested inside.
while ( $scanner->next_delimiter() ) {
$type = $scanner->get_delimiter_type();
$block_type = $scanner->get_block_type();
$is_ignored_block = in_array( $block_type, $blocks_to_ignore, true );
switch ( $type ) {
case Block_Scanner::OPENER:
// If this is an ignored block, increase our nesting depth.
if ( $is_ignored_block ) {
++$ignored_block_depth;
}
// Only collect blocks that are not inside any ignored block.
if ( 0 === $ignored_block_depth ) {
$found_blocks[] = $block_type;
}
break;
case Block_Scanner::CLOSER:
// If this closes an ignored block, decrease our nesting depth.
if ( $is_ignored_block && $ignored_block_depth > 0 ) {
--$ignored_block_depth;
}
break;
case Block_Scanner::VOID:
// Void blocks: only collect if we're not inside an ignored block and this isn't an ignored block itself.
if ( 0 === $ignored_block_depth && ! $is_ignored_block ) {
$found_blocks[] = $block_type;
}
break;
}
}
set_transient( $additional_blocks_on_page_transient_name, $found_blocks, DAY_IN_SECONDS );
return $found_blocks;
}
/**
* Gets an array containing the block or shortcode use properties for the Cart page.
*
* @return array An array containing the block or shortcode use properties for the Cart page.
*/
public function get_cart_page_block_usage() {
$new_info = array();
$content = $this->cart_content_source;
$block_presence = str_contains( $content, '<!-- wp:woocommerce/cart' );
$shortcode_presence = str_contains( $content, '[woocommerce_cart]' );
$classic_shortcode_presence = str_contains( $content, '<!-- wp:woocommerce/classic-shortcode {"shortcode":"cart"}' );
$new_info['cart_page_contains_cart_block'] = $block_presence ? '1' : '0';
$new_info['cart_page_contains_cart_shortcode'] = $shortcode_presence || $classic_shortcode_presence ? '1' : '0';
return $new_info;
}
/**
* Gets an array containing the block or shortcode use properties for the Checkout page.
*
* @return array An array containing the block or shortcode use properties for the Checkout page.
*/
public function get_checkout_page_block_usage() {
$new_info = array();
$content = $this->checkout_content_source;
$block_presence = str_contains( $content, '<!-- wp:woocommerce/checkout' );
$shortcode_presence = str_contains( $content, '[woocommerce_checkout]' );
$classic_shortcode_presence = str_contains( $content, '<!-- wp:woocommerce/classic-shortcode {"shortcode":"checkout"}' );
$new_info['checkout_page_contains_checkout_block'] = $block_presence ? '1' : '0';
$new_info['checkout_page_contains_checkout_shortcode'] = $shortcode_presence || $classic_shortcode_presence ? '1' : '0';
return $new_info;
}
/**
* Get info about the cart & checkout pages, in particular
* whether the store is using shortcodes or Gutenberg blocks.
* This info is cached in a transient.
*
* Note: similar code is in a WooCommerce core PR:
* https://github.com/woocommerce/woocommerce/pull/25932
*
* @return array
*/
public function get_cart_checkout_info() {
$info = array_merge(
$this->get_cart_page_block_usage(),
$this->get_checkout_page_block_usage()
);
return $info;
}
/**
* Search a specific post for text content.
*
* Note: similar code is in a WooCommerce core PR:
* https://github.com/woocommerce/woocommerce/pull/25932
*
* @param integer $post_id The id of the post to search.
* @param string $text The text to search for.
* @return integer 1 if post contains $text (otherwise 0).
*/
public function post_contains_text( $post_id, $text ) {
global $wpdb;
// Search for the text anywhere in the post.
$wildcarded = "%{$text}%";
// No better way to search post content without having filters expanding blocks.
// This is already cached up in the parent function.
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
"
SELECT COUNT( * ) FROM {$wpdb->prefix}posts
WHERE ID=%d
AND {$wpdb->prefix}posts.post_content LIKE %s
",
array( $post_id, $wildcarded )
)
);
return ( '0' !== $result ) ? 1 : 0;
}
/**
* Get the cart total
*
* @return float
*/
public function get_cart_total() {
$cart = WC()->cart;
if ( $cart === null ) {
return 0;
}
return $cart->get_total( 'tracking' );
}
/**
* Get the cart subtotal
*
* @return float
*/
public function get_cart_subtotal() {
$cart = WC()->cart;
if ( $cart === null ) {
return 0;
}
return $cart->get_subtotal();
}
/**
* Get the cart shipping total
*
* @return float
*/
public function get_cart_shipping_total() {
$cart = WC()->cart;
if ( $cart === null ) {
return 0;
}
return $cart->get_shipping_total();
}
/**
* Get the cart taxes
*
* @return float
*/
public function get_cart_taxes() {
$cart = WC()->cart;
if ( $cart === null ) {
return 0;
}
return $cart->get_taxes_total();
}
/**
* Get the cart discount total
*
* @return float
*/
public function get_total_discounts() {
$cart = WC()->cart;
if ( $cart === null ) {
return 0;
}
return $cart->get_discount_total();
}
/**
* Get number of items in the cart
*
* @return int
*/
public function get_cart_items_count() {
$cart = WC()->cart;
if ( $cart === null ) {
return 0;
}
return $cart->get_cart_contents_count();
}
/**
* Retrieves the breadcrumb trail as an array of page titles.
*
* This function attempts to generate a hierarchical breadcrumb trail for the current page or post.
* - For the front page, it returns "Home".
* - For WooCommerce product, category, or tag pages, it uses the WooCommerce breadcrumb generator and prepends the shop page title if needed.
* - For regular pages, it builds the breadcrumb from the page's ancestors, ordered from top-level to current.
* - For all other cases, it returns the current page's title.
*
* @return array The breadcrumb trail as an array of titles.
*/
private function get_breadcrumb_titles() {
if ( is_front_page() ) {
return array( __( 'Home', 'woocommerce-analytics' ) );
}
if ( class_exists( '\WC_Breadcrumb' ) ) {
$breadcrumb = new \WC_Breadcrumb();
$crumbs = $breadcrumb->generate();
$titles = wp_list_pluck( $crumbs, 0 );
if ( is_product() || is_product_category() || is_product_tag() ) {
$titles = $this->prepend_shop_page_title( $titles );
}
if ( ! empty( $titles ) ) {
return $titles;
}
}
// If it's a page, get the hierarchical title.
if ( is_page() ) {
$titles = array();
$page_id = get_queried_object_id();
$ancestors = get_post_ancestors( $page_id );
// Reverse the ancestors to get the top-level first.
$ancestors = array_reverse( $ancestors );
foreach ( $ancestors as $ancestor ) {
$titles[] = get_the_title( $ancestor );
}
$titles[] = get_the_title( $page_id );
return $titles;
}
return array( get_the_title() );
}
/**
* Prepend the shop page title if it's not already present.
*
* @param array $titles The titles to prepend the shop page title to.
* @return array The titles with the shop page title prepended.
*/
private function prepend_shop_page_title( array $titles ) {
$shop_page_id = wc_get_page_id( 'shop' );
if ( ! $shop_page_id ) {
return $titles;
}
$shop_page_title = get_the_title( $shop_page_id );
if ( ! $shop_page_title || ( ! empty( $titles ) && $titles[0] === $shop_page_title ) ) {
return $titles;
}
return array_merge( array( $shop_page_title ), $titles );
}
}
@@ -0,0 +1,388 @@
<?php
/**
* Main class for the WooCommerce Analytics package.
* Originally ported from the Jetpack_Google_Analytics code.
*
* @package automattic/woocommerce-analytics
*/
namespace Automattic;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection;
use Automattic\Woocommerce_Analytics\My_Account;
use Automattic\Woocommerce_Analytics\Universal;
use Automattic\Woocommerce_Analytics\WC_Analytics_Tracking_Proxy;
use Composer\InstalledVersions;
/**
* Instantiate WooCommerce Analytics
*/
class Woocommerce_Analytics {
/**
* Package version.
*/
const PACKAGE_VERSION = '0.16.3';
/**
* Proxy speed module version option.
*
* @var string
*/
const PROXY_SPEED_MODULE_VERSION_OPTION = 'woocommerce_analytics_proxy_speed_module_version';
/**
* Proxy speed module version check transient.
*
* @var string
*/
const PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT = 'woocommerce_analytics_proxy_speed_module_version_check';
/**
* Initializer.
* Used to configure the WooCommerce Analytics package.
*
* @return void
*/
public static function init() {
if ( ! self::should_track_store() || did_action( 'woocommerce_analytics_init' ) ) {
return;
}
// loading _wca.
add_action( 'wp_head', array( __CLASS__, 'wp_head_top' ), 1 );
// loading s.js.
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_tracking_script' ) );
// loading client-side analytics script.
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_client_script' ) );
// Initialize general store tracking actions.
add_action( 'init', array( new Universal(), 'init_hooks' ) );
add_action( 'init', array( new My_Account(), 'init_hooks' ) );
// Initialize REST API endpoints.
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
/**
* Fires after the WooCommerce Analytics package is initialized
*
* @since 0.1.5
*/
do_action( 'woocommerce_analytics_init' );
}
/**
* WooCommerce Analytics is only available to Jetpack connected WooCommerce stores
* with WooCommerce version 3.0 or higher
*
* @return bool
*/
public static function should_track_store() {
// Ensure this is available, even with mu-plugins.
if ( ! function_exists( 'is_plugin_active' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
/**
* Make sure WooCommerce is installed and active
*
* This action is documented in https://docs.woocommerce.com/document/create-a-plugin
*/
if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) ) {
return false;
}
// Ensure the WooCommerce class exists and is a valid version.
$minimum_woocommerce_active = class_exists( 'WooCommerce' ) && version_compare( \WC_VERSION, '3.0', '>=' );
if ( ! $minimum_woocommerce_active ) {
return false;
}
// Ensure the WC Tracks classes exist.
if ( ! class_exists( 'WC_Tracks' ) ) {
if ( ! defined( 'WC_ABSPATH' ) || ! file_exists( WC_ABSPATH . 'includes/tracks/class-wc-tracks.php' ) ) {
return false;
}
include_once WC_ABSPATH . 'includes/tracks/class-wc-tracks.php';
include_once WC_ABSPATH . 'includes/tracks/class-wc-tracks-event.php';
include_once WC_ABSPATH . 'includes/tracks/class-wc-tracks-client.php';
}
add_action( 'admin_init', array( __CLASS__, 'maybe_update_proxy_speed_module' ) );
// Tracking only Site pages.
if ( is_admin() || wp_doing_ajax() || wp_is_xml_request() || is_login() || is_feed() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return false;
}
// Make sure the site is connected to WordPress.com.
if ( ! ( new Jetpack_Connection() )->is_connected() ) {
return false;
}
return true;
}
/**
* Make _wca available to queue events
*/
public static function wp_head_top() {
if ( is_cart() || is_checkout() || is_checkout_pay_page() || is_order_received_page() || is_add_payment_method_page() ) {
echo '<script>window._wca_prevent_referrer = true;</script>' . "\r\n";
}
echo '<script>window._wca = window._wca || [];</script>' . "\r\n";
}
/**
* Place script to call s.js, Store Analytics.
*/
public static function enqueue_tracking_script() {
$url = sprintf(
'https://stats.wp.com/s-%d.js',
gmdate( 'YW' )
);
wp_enqueue_script(
'woocommerce-analytics',
$url,
array(),
null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
array(
'in_footer' => false,
'strategy' => 'defer',
)
);
}
/**
* Enqueue client-side analytics script.
*/
public static function enqueue_client_script() {
Assets::register_script(
'woocommerce-analytics-client',
'../build/woocommerce-analytics-client.js',
__FILE__,
array(
'in_footer' => true,
'strategy' => 'defer',
'enqueue' => true,
)
);
}
/**
* Register REST API routes.
*/
public static function register_rest_routes() {
$controller = new WC_Analytics_Tracking_Proxy();
$controller->register_routes();
}
/**
* Maybe update proxy speed module.
*/
public static function maybe_update_proxy_speed_module() {
// Skip if we've already checked recently.
if ( get_transient( self::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT ) ) {
return;
}
$version = get_option( self::PROXY_SPEED_MODULE_VERSION_OPTION, false );
if ( \Automattic\Woocommerce_Analytics\Features::is_proxy_speed_module_enabled() ) {
if ( $version !== self::PACKAGE_VERSION ) {
self::maybe_add_proxy_speed_module();
}
} elseif ( $version !== false ) {
self::maybe_remove_proxy_speed_module();
}
// Set the transient after the update attempt to prevent checking on every admin_init.
// If the update failed, it will be retried after the transient expires (1 day).
set_transient( self::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT, 1, DAY_IN_SECONDS );
}
/**
* Maybe add proxy speed module.
*/
public static function maybe_add_proxy_speed_module() {
if ( ! \Automattic\Woocommerce_Analytics\Features::is_proxy_speed_module_enabled() ) {
return;
}
if ( ! self::init_filesystem() ) {
return;
}
global $wp_filesystem;
// Create the mu-plugin directory if it doesn't exist.
if ( ! is_dir( WPMU_PLUGIN_DIR ) ) {
wp_mkdir_p( WPMU_PLUGIN_DIR );
}
// If the mu-plugin directory doesn't exist, we can't copy the files.
if ( ! is_dir( WPMU_PLUGIN_DIR ) ) {
return;
}
// Check if the mu-plugin directory is writable.
if ( ! $wp_filesystem->is_writable( WPMU_PLUGIN_DIR ) ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->debug( 'WooCommerce Analytics proxy speed module not installed: mu-plugins directory is not writable.', array( 'source' => 'woocommerce-analytics' ) );
}
return;
}
if ( get_option( self::PROXY_SPEED_MODULE_VERSION_OPTION ) === self::PACKAGE_VERSION ) {
// No need to copy the files again.
return;
}
$mu_plugin_src_file = __DIR__ . '/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php';
$mu_plugin_dest_file = trailingslashit( WPMU_PLUGIN_DIR ) . 'woocommerce-analytics-proxy-speed-module.php';
// Verify source file exists before attempting to copy.
if ( ! file_exists( $mu_plugin_src_file ) ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( 'WooCommerce Analytics proxy speed module source file not found.', array( 'source' => 'woocommerce-analytics' ) );
}
return;
}
$content = $wp_filesystem->get_contents( $mu_plugin_src_file );
if ( false === $content ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( 'Failed to read the WooCommerce Analytics proxy speed module source file.', array( 'source' => 'woocommerce-analytics' ) );
}
return;
}
// Get the autoloader path from the current plugin location.
$autoloader_path = self::locate_autoloader_file();
if ( null === $autoloader_path ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( 'WooCommerce Analytics proxy speed module not installed: could not locate autoloader.', array( 'source' => 'woocommerce-analytics' ) );
}
return;
}
// Replace placeholders with actual values.
$content = str_replace(
array( '{{AUTOLOADER_PATH}}', '{{VERSION}}' ),
array( $autoloader_path, self::PACKAGE_VERSION ),
$content
);
if ( ! $wp_filesystem->put_contents( $mu_plugin_dest_file, $content ) ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( 'Failed to write the WooCommerce Analytics proxy speed module file.', array( 'source' => 'woocommerce-analytics' ) );
}
return;
}
update_option( self::PROXY_SPEED_MODULE_VERSION_OPTION, self::PACKAGE_VERSION );
}
/**
* Maybe removes the proxy speed module. This should be invoked when the plugin is deactivated.
*/
public static function maybe_remove_proxy_speed_module() {
if ( ! self::init_filesystem() ) {
return;
}
global $wp_filesystem;
/**
* Clean up MU plugin.
*/
$file_path = trailingslashit( WPMU_PLUGIN_DIR ) . 'woocommerce-analytics-proxy-speed-module.php';
if ( $wp_filesystem->exists( $file_path ) && $wp_filesystem->is_writable( $file_path ) ) {
$deleted = $wp_filesystem->delete( $file_path );
if ( ! $deleted && function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( 'Failed to delete WooCommerce Analytics proxy speed module file. The MU-plugin may continue running.', array( 'source' => 'woocommerce-analytics' ) );
}
}
delete_option( self::PROXY_SPEED_MODULE_VERSION_OPTION );
delete_transient( self::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT );
}
/**
* Finds the path to the autoloader file.
*
* Uses multiple strategies to locate the autoloader, since this package
* can be included in different plugins (Jetpack, WooCommerce Analytics, etc.):
* 1. Jetpack autoloader global (if available)
* 2. Composer's InstalledVersions API
* 3. Directory-based guessing as a fallback
*
* @return string|null The path to the autoloader file, or null if not found.
*/
private static function locate_autoloader_file() {
global $jetpack_autoloader_loader;
$autoload_file = null;
// Try the Jetpack autoloader.
if ( isset( $jetpack_autoloader_loader ) ) {
$class_file = $jetpack_autoloader_loader->find_class_file( self::class );
if ( $class_file ) {
// Walk up 5 levels: src/ → woocommerce-analytics/ → automattic/ → jetpack_vendor/ → plugin root.
$autoload_file = dirname( $class_file, 5 ) . '/vendor/autoload.php';
}
}
// Try Composer's InstalledVersions API.
if ( null === $autoload_file
&& is_callable( array( InstalledVersions::class, 'getInstallPath' ) )
&& InstalledVersions::isInstalled( 'automattic/woocommerce-analytics' )
) {
$package_file = InstalledVersions::getInstallPath( 'automattic/woocommerce-analytics' );
$expected_suffix = '/automattic/woocommerce-analytics';
if ( substr( $package_file, -strlen( $expected_suffix ) ) === $expected_suffix ) {
// Walk up 3 levels: woocommerce-analytics/ → automattic/ → jetpack_vendor/ → plugin root.
$autoload_file = dirname( $package_file, 3 ) . '/vendor/autoload.php';
}
}
// Guess based on directory structure.
// First try standard vendor layout (vendor/automattic/woocommerce-analytics/src/),
// then try standalone package with its own vendor dir.
if ( null === $autoload_file ) {
// Walk up 4 levels from src/: woocommerce-analytics/ → automattic/ → vendor/ → project root.
$autoload_file = dirname( __DIR__, 4 ) . '/vendor/autoload.php';
if ( ! file_exists( $autoload_file ) ) {
$autoload_file = dirname( __DIR__ ) . '/vendor/autoload.php';
}
}
if ( ! file_exists( $autoload_file ) ) {
return null;
}
return $autoload_file;
}
/**
* Initialize the WP filesystem.
*
* @return bool True if filesystem is initialized, false otherwise.
*/
private static function init_filesystem() {
if ( ! function_exists( 'WP_Filesystem' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
// Initialize the WP filesystem.
ob_start();
$initialized = WP_Filesystem();
ob_end_clean();
return $initialized;
}
}
@@ -0,0 +1,295 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Plugin Name: WooCommerce Analytics - Proxy Speed Module
* Description: Speeds up WooCommerce Analytics' proxy by handling requests at MU-plugin stage and exiting early.
* Plugin URI: https://woocommerce.com
* Author: WooCommerce
* Version: {{VERSION}}
* Author URI: https://woocommerce.com
*
* Text Domain: woocommerce-analytics
*
* This module intercepts proxy tracking requests at the MU-plugin stage (before regular plugins load)
* and handles them completely, then exits. This dramatically reduces response time by avoiding
* the full WordPress plugin initialization.
*/
defined( 'ABSPATH' ) || exit;
/**
* WooCommerce Analytics Proxy Speed Module
*/
class WooCommerceAnalyticsProxySpeed {
/**
* Path of the proxy request.
*
* @var string
*/
const PROXY_REQUEST_PATH = 'woocommerce-analytics/v1/track';
/**
* Autoloader path - this placeholder is replaced during installation.
* DO NOT MODIFY - this value is injected by the parent plugin.
*
* @var string
*/
const AUTOLOADER_PATH = '{{AUTOLOADER_PATH}}';
/**
* Initialize the proxy speed module.
*
* @return void
*/
public function init() {
// Only intercept POST requests to the proxy endpoint.
if ( ! $this->is_proxy_request() ) {
return;
}
// If autoloader failed, let WordPress continue loading
// and fallback to the regular REST API.
if ( ! $this->load_autoloader() ) {
return;
}
// Handle the request completely and exit.
$this->handle_proxy_request();
exit;
}
/**
* Check if current request is a proxy request.
*
* @return bool
*/
private function is_proxy_request() {
if ( 'POST' !== $this->get_request_method() ) {
return false;
}
$path = $this->get_request_path();
if ( '' === $path ) {
return false;
}
$normalized_path = rtrim( $path, '/' );
$proxy_suffix = '/' . ltrim( self::PROXY_REQUEST_PATH, '/' );
if ( strlen( $normalized_path ) < strlen( $proxy_suffix ) ) {
return false;
}
return substr( $normalized_path, -strlen( $proxy_suffix ) ) === $proxy_suffix;
}
/**
* Load the autoloader.
*
* At MU-plugin stage, plugins haven't loaded yet, so we bootstrap
* the autoloader directly using the path injected during installation.
*
* @return bool True if autoloader loaded and classes are available.
*/
private function load_autoloader() {
$autoload_path = self::AUTOLOADER_PATH;
// Validate the path was properly injected (not still a placeholder).
if ( strpos( $autoload_path, '{{' ) !== false ) {
error_log( 'WooCommerce Analytics Proxy Speed Module: Autoloader path placeholder was not replaced during installation.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return false;
}
if ( ! file_exists( $autoload_path ) ) {
error_log( 'WooCommerce Analytics Proxy Speed Module: Autoloader file not found at: ' . $autoload_path ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return false;
}
try {
require_once $autoload_path;
} catch ( \Throwable $e ) {
error_log( 'WooCommerce Analytics Proxy Speed Module: Failed to load autoloader: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return false;
}
if ( ! class_exists( '\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking' ) ) {
error_log( 'WooCommerce Analytics Proxy Speed Module: WC_Analytics_Tracking class not found after loading autoloader.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return false;
}
return true;
}
/**
* Handle the proxy request completely.
*
* Processes the events and sends the response without loading
* regular WordPress plugins.
*
* @return void
*/
private function handle_proxy_request() {
if ( ! headers_sent() ) {
header( 'Content-Type: application/json; charset=utf-8' );
header( 'Cache-Control: no-cache, must-revalidate' );
}
try {
$this->process_proxy_request();
} catch ( \Throwable $e ) {
error_log( 'WooCommerce Analytics Proxy Speed Module: Uncaught error in handle_proxy_request: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
$this->send_json_response(
array(
'success' => false,
'error' => 'Internal server error while processing analytics events.',
),
500
);
}
}
/**
* Process the proxy request body: parse events, record them, and send the response.
*
* @return void
*/
private function process_proxy_request() {
// Apply magic quotes to superglobals ($_GET, $_POST, $_COOKIE, $_REQUEST) for compatibility with the regular API flow.
if ( function_exists( 'wp_magic_quotes' ) ) {
wp_magic_quotes();
}
$body = file_get_contents( 'php://input' );
if ( empty( $body ) ) {
$this->send_json_response(
array(
'success' => false,
'error' => 'Empty request body',
),
400
);
return;
}
$events = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
$this->send_json_response(
array(
'success' => false,
'error' => 'Invalid JSON',
),
400
);
return;
}
// Normalize: wrap a single event object or unexpected scalar in an array.
if ( ! is_array( $events ) || isset( $events['event_name'] ) ) {
$events = array( $events );
}
$results = array();
$has_errors = false;
foreach ( $events as $index => $event ) {
if ( empty( $event ) || ! is_array( $event ) ) {
$results[ $index ] = array(
'success' => false,
'error' => 'Invalid event format',
);
$has_errors = true;
continue;
}
$event_name = $event['event_name'] ?? null;
$properties = $event['properties'] ?? array();
if ( ! $event_name || ! is_array( $properties ) ) {
$results[ $index ] = array(
'success' => false,
'error' => 'Missing event_name or invalid properties',
);
$has_errors = true;
continue;
}
$result = \Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::record_event( $event_name, $properties );
if ( is_wp_error( $result ) ) {
$results[ $index ] = array(
'success' => false,
'error' => $result->get_error_message(),
);
$has_errors = true;
continue;
}
$results[ $index ] = array( 'success' => true );
}
\Automattic\Woocommerce_Analytics\WC_Analytics_Tracking::send_batched_pixels();
$this->send_json_response(
array(
'success' => ! $has_errors,
'results' => $results,
'is_proxy_speed_module' => true,
),
$has_errors ? 207 : 200
);
}
/**
* Send a JSON response.
*
* @param array $data Response data.
* @param int $status_code HTTP status code.
* @return void
*/
private function send_json_response( $data, $status_code = 200 ) {
http_response_code( $status_code );
echo wp_json_encode( $data, JSON_UNESCAPED_SLASHES );
}
/**
* Helper method to retrieve the request path.
*
* Extracts and validates the path component from $_SERVER['REQUEST_URI']
* for safe internal matching.
*
* @return string The validated path, or empty string on failure.
*/
private function get_request_path() {
$raw_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
if ( ! is_string( $raw_uri ) ) {
return '';
}
// Extract just the path component to avoid matching against query strings, etc.
$path = wp_parse_url( $raw_uri, PHP_URL_PATH );
if ( ! is_string( $path ) ) {
return '';
}
// Ensure the path contains only expected URL path characters.
if ( preg_match( '/[^A-Za-z0-9\-._~\/]/', $path ) ) {
return '';
}
return $path;
}
/**
* Helper method to get request method.
*
* @return string
*/
private function get_request_method() {
return isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}
}
( new WooCommerceAnalyticsProxySpeed() )->init();